/**
 * Copyright (c) 2010-2019 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.openhab.binding.denonmarantz.internal.connector.http;

import java.beans.Introspector;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.UnmarshalException;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.stream.util.StreamReaderDelegate;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.smarthome.io.net.http.HttpUtil;
import org.openhab.binding.denonmarantz.internal.DenonMarantzState;
import org.openhab.binding.denonmarantz.internal.config.DenonMarantzConfiguration;
import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnector;
import org.openhab.binding.denonmarantz.internal.xml.entities.Deviceinfo;
import org.openhab.binding.denonmarantz.internal.xml.entities.Main;
import org.openhab.binding.denonmarantz.internal.xml.entities.ZoneStatus;
import org.openhab.binding.denonmarantz.internal.xml.entities.ZoneStatusLite;
import org.openhab.binding.denonmarantz.internal.xml.entities.commands.AppCommandRequest;
import org.openhab.binding.denonmarantz.internal.xml.entities.commands.AppCommandResponse;
import org.openhab.binding.denonmarantz.internal.xml.entities.commands.CommandRx;
import org.openhab.binding.denonmarantz.internal.xml.entities.commands.CommandTx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class makes the connection to the receiver and manages it.
 * It is also responsible for sending commands to the receiver.
 * *
 *
 * @author Jeroen Idserda - Initial Contribution (1.x Binding)
 * @author Jan-Willem Veldhuis - Refactored for 2.x
 */
public class DenonMarantzHttpConnector extends DenonMarantzConnector {

    private Logger logger = LoggerFactory.getLogger(DenonMarantzHttpConnector.class);

    private static final int REQUEST_TIMEOUT_MS = 5000; // 5 seconds

    // Main URL for the receiver
    private static final String URL_MAIN = "formMainZone_MainZoneXml.xml";

    // Main Zone Status URL
    private static final String URL_ZONE_MAIN = "formMainZone_MainZoneXmlStatus.xml";

    // Secondary zone lite status URL (contains less info)
    private static final String URL_ZONE_SECONDARY_LITE = "formZone%d_Zone%dXmlStatusLite.xml";

    // Device info URL
    private static final String URL_DEVICE_INFO = "Deviceinfo.xml";

    // URL to send app commands to
    private static final String URL_APP_COMMAND = "AppCommand.xml";

    private static final String CONTENT_TYPE_XML = "application/xml";

    private final String cmdUrl;

    private final String statusUrl;

    private final HttpClient httpClient;

    private ScheduledFuture<?> pollingJob;

    public DenonMarantzHttpConnector(DenonMarantzConfiguration config, DenonMarantzState state,
            ScheduledExecutorService scheduler, HttpClient httpClient) {
        this.config = config;
        this.scheduler = scheduler;
        this.state = state;
        this.cmdUrl = String.format("http://%s:%d/goform/formiPhoneAppDirect.xml?", config.getHost(),
                config.getHttpPort());
        this.statusUrl = String.format("http://%s:%d/goform/", config.getHost(), config.getHttpPort());
        this.httpClient = httpClient;
    }

    public DenonMarantzState getState() {
        return state;
    }

    /**
     * Set up the connection to the receiver by starting to poll the HTTP API.
     */
    @Override
    public void connect() {
        if (!isPolling()) {
            logger.debug("HTTP polling started.");
            try {
                setConfigProperties();
            } catch (IOException e) {
                logger.debug("IO error while retrieving document:", e);
                state.connectionError("IO error while connecting to AVR: " + e.getMessage());
                return;
            }

            pollingJob = scheduler.scheduleWithFixedDelay(() -> {
                try {
                    refreshHttpProperties();
                } catch (IOException e) {
                    logger.debug("IO error while retrieving document", e);
                    state.connectionError("IO error while connecting to AVR: " + e.getMessage());
                    stopPolling();
                } catch (RuntimeException e) {
                    /**
                     * We need to catch this RuntimeException, as otherwise the polling stops.
                     * Log as error as it could be a user configuration error.
                     */
                    StringBuilder sb = new StringBuilder();
                    for (StackTraceElement s : e.getStackTrace()) {
                        sb.append(s.toString()).append("\n");
                    }
                    logger.error("Error while polling Http: \"{}\". Stacktrace: \n{}", e.getMessage(), sb.toString());
                }
            }, 0, config.httpPollingInterval, TimeUnit.SECONDS);
        }
    }

    private boolean isPolling() {
        return pollingJob != null && !pollingJob.isCancelled();
    }

    private void stopPolling() {
        if (isPolling()) {
            pollingJob.cancel(true);
            logger.debug("HTTP polling stopped.");
        }
    }

    /**
     * Shutdown the http client
     */
    @Override
    public void dispose() {
        logger.debug("disposing connector");

        stopPolling();
    }

    @Override
    protected void internalSendCommand(String command) {
        logger.debug("Sending command '{}'", command);
        if (StringUtils.isBlank(command)) {
            logger.warn("Trying to send empty command");
            return;
        }

        try {
            String url = cmdUrl + URLEncoder.encode(command, Charset.defaultCharset().displayName());
            logger.trace("Calling url {}", url);

            httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).send(new Response.CompleteListener() {
                @Override
                public void onComplete(Result result) {
                    if (result.getResponse().getStatus() != 200) {
                        logger.warn("Error {} while sending command", result.getResponse().getReason());
                    }
                }
            });

        } catch (UnsupportedEncodingException e) {
            logger.warn("Error sending command", e);
        }
    }

    private void updateMain() throws IOException {
        String url = statusUrl + URL_MAIN;
        logger.trace("Refreshing URL: {}", url);

        Main statusMain = getDocument(url, Main.class);
        if (statusMain != null) {
            state.setPower(statusMain.getPower().getValue());
        }
    }

    private void updateMainZone() throws IOException {
        String url = statusUrl + URL_ZONE_MAIN;
        logger.trace("Refreshing URL: {}", url);

        ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
        if (mainZone != null) {
            state.setInput(mainZone.getInputFuncSelect().getValue());
            state.setMainVolume(mainZone.getMasterVolume().getValue());
            state.setMainZonePower(mainZone.getPower().getValue());
            state.setMute(mainZone.getMute().getValue());

            if (config.inputOptions == null) {
                config.inputOptions = mainZone.getInputFuncList();
            }

            if (mainZone.getSurrMode() == null) {
                logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
            } else {
                state.setSurroundProgram(mainZone.getSurrMode().getValue());
            }
        }
    }

    private void updateSecondaryZones() throws IOException {
        for (int i = 2; i <= config.getZoneCount(); i++) {
            String url = String.format("%s" + URL_ZONE_SECONDARY_LITE, statusUrl, i, i);
            logger.trace("Refreshing URL: {}", url);
            ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
            if (zoneSecondary != null) {
                switch (i) {
                    // maximum 2 secondary zones are supported
                    case 2:
                        state.setZone2Power(zoneSecondary.getPower().getValue());
                        state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
                        state.setZone2Mute(zoneSecondary.getMute().getValue());
                        state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
                        break;
                    case 3:
                        state.setZone3Power(zoneSecondary.getPower().getValue());
                        state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
                        state.setZone3Mute(zoneSecondary.getMute().getValue());
                        state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
                        break;
                }
            }
        }
    }

    private void updateDisplayInfo() throws IOException {
        String url = statusUrl + URL_APP_COMMAND;
        logger.trace("Refreshing URL: {}", url);

        AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
        AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);

        if (response != null) {
            CommandRx titleInfo = response.getCommands().get(0);
            state.setNowPlayingArtist(titleInfo.getText("artist"));
            state.setNowPlayingAlbum(titleInfo.getText("album"));
            state.setNowPlayingTrack(titleInfo.getText("track"));
        }
    }

    private boolean setConfigProperties() throws IOException {
        String url = statusUrl + URL_DEVICE_INFO;
        logger.debug("Refreshing URL: {}", url);

        Deviceinfo deviceinfo = getDocument(url, Deviceinfo.class);
        if (deviceinfo != null) {
            config.setZoneCount(deviceinfo.getDeviceZones());
        }

        /**
         * The maximum volume is received from the telnet connection in the
         * form of the MVMAX property. It is not always received reliable however,
         * so we're using a default for now.
         */
        config.setMainVolumeMax(DenonMarantzConfiguration.MAX_VOLUME);

        // if deviceinfo is null, something went wrong (and is logged in getDocument catch blocks)
        return (deviceinfo != null);
    }

    private void refreshHttpProperties() throws IOException {
        logger.trace("Refreshing Denon status");

        updateMain();
        updateMainZone();
        updateSecondaryZones();
        updateDisplayInfo();
    }

    @Nullable
    private <T> T getDocument(String uri, Class<T> response) throws IOException {
        try {
            String result = HttpUtil.executeUrl("GET", uri, REQUEST_TIMEOUT_MS);
            logger.trace("result of getDocument for uri '{}':\r\n{}", uri, result);

            if (StringUtils.isNotBlank(result)) {
                JAXBContext jc = JAXBContext.newInstance(response);
                XMLInputFactory xif = XMLInputFactory.newInstance();
                XMLStreamReader xsr = xif.createXMLStreamReader(IOUtils.toInputStream(result));
                xsr = new PropertyRenamerDelegate(xsr);

                @SuppressWarnings("unchecked")
                T obj = (T) jc.createUnmarshaller().unmarshal(xsr);

                return obj;
            }
        } catch (UnmarshalException e) {
            logger.debug("Failed to unmarshal xml document: {}", e.getMessage());
        } catch (JAXBException e) {
            logger.debug("Unexpected error occurred during unmarshalling of document: {}", e.getMessage());
        } catch (XMLStreamException e) {
            logger.debug("Communication error: {}", e.getMessage());
        }

        return null;
    }

    @Nullable
    private <T, S> T postDocument(String uri, Class<T> response, S request) throws IOException {
        try {
            JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
            Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
            StringWriter sw = new StringWriter();
            jaxbMarshaller.marshal(request, sw);

            ByteArrayInputStream inputStream = new ByteArrayInputStream(sw.toString().getBytes(StandardCharsets.UTF_8));
            String result = HttpUtil.executeUrl("POST", uri, inputStream, CONTENT_TYPE_XML, REQUEST_TIMEOUT_MS);

            if (StringUtils.isNotBlank(result)) {
                JAXBContext jcResponse = JAXBContext.newInstance(response);

                @SuppressWarnings("unchecked")
                T obj = (T) jcResponse.createUnmarshaller().unmarshal(IOUtils.toInputStream(result));

                return obj;
            }
        } catch (JAXBException e) {
            logger.debug("Encoding error in post", e);
        }

        return null;
    }

    private static class PropertyRenamerDelegate extends StreamReaderDelegate {

        public PropertyRenamerDelegate(XMLStreamReader xsr) {
            super(xsr);
        }

        @Override
        public String getAttributeLocalName(int index) {
            return Introspector.decapitalize(super.getAttributeLocalName(index));
        }

        @Override
        public String getLocalName() {
            return Introspector.decapitalize(super.getLocalName());
        }
    }

}
