package io.klerch.alexa.test.client;

import com.amazon.speech.json.SpeechletRequestEnvelope;
import com.amazon.speech.json.SpeechletRequestModule;
import com.amazon.speech.speechlet.*;
import com.amazon.speech.speechlet.interfaces.audioplayer.AudioPlayerInterface;
import com.amazon.speech.speechlet.interfaces.display.DisplayInterface;
import com.amazonaws.util.StringUtils;
import com.esotericsoftware.yamlbeans.YamlException;
import com.esotericsoftware.yamlbeans.YamlReader;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.klerch.alexa.test.client.endpoint.AlexaEndpoint;
import io.klerch.alexa.test.client.endpoint.AlexaEndpointFactory;
import io.klerch.alexa.test.request.AlexaRequest;
import io.klerch.alexa.test.response.AlexaResponse;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.log4j.Logger;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;

import static io.klerch.alexa.test.client.AlexaClient.API_ENDPOINT.EU;
import static io.klerch.alexa.test.client.AlexaClient.API_ENDPOINT.NA;

public class AlexaClient {
    private final static Logger log = Logger.getLogger(AlexaClient.class);
    private final static ObjectMapper mapper = new ObjectMapper();
    public static final String VERSION = "1.0";
    final AlexaEndpoint endpoint;
    private AlexaResponse lastResponse;
    final String apiEndpoint;
    private final long millisFromCurrentDate;
    private long lastExecutionTimeMillis;
    private final Locale locale;
    final Device device;
    private final Application application;
    private final User user;
    private final Optional<String> debugFlagSessionAttributeName;
    private final Object yLaunch;

    private static Map<API_ENDPOINT, String> apiEndpoints = new HashMap<>();

    static {
        mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
        mapper.enable(SerializationFeature.WRITE_NULL_MAP_VALUES);
        mapper.registerModule(new SpeechletRequestModule());

        apiEndpoints.putIfAbsent(NA, "https://api.amazonalexa.com/");
        apiEndpoints.putIfAbsent(EU, "https://api.eu.amazonalexa.com/");
    }

    public enum API_ENDPOINT {
        NA, EU
    }

    AlexaClient(final AlexaClientBuilder builder) {
        this.millisFromCurrentDate = builder.timestamp.getTime() - new Date().getTime();
        this.locale = builder.locale;
        apiEndpoint = apiEndpoints.getOrDefault(builder.apiEndpoint, apiEndpoints.get(NA));
        this.application = new Application(builder.applicationId);
        this.user = User.builder().withUserId(builder.uid).withAccessToken(builder.accessToken).build();
        this.device = builder.device;
        this.debugFlagSessionAttributeName = StringUtils.isNullOrEmpty(builder.debugFlagSessionAttributeName) ? Optional.empty() : Optional.of(builder.debugFlagSessionAttributeName);
        this.endpoint = builder.endpoint;
        this.yLaunch = builder.yLaunch;
    }

    public AlexaResponse getLastResponse() {
        return this.lastResponse;
    }

    public long getLastExecutionMillis() {
        return lastExecutionTimeMillis;
    }

    public AlexaEndpoint getEndpoint() {
        return this.endpoint;
    }

    public static String generateUserId() {
        return String.format("amzn1.ask.account.%s", RandomStringUtils.randomAlphanumeric(207).toUpperCase());
    }

    public static String generateApplicationId() {
        return String.format("amzn1.ask.skill.%s", UUID.randomUUID());
    }

    public Date getCurrentTimestamp() {
        return new Date(new Date().getTime() + millisFromCurrentDate);
    }

    public Application getApplication() { return this.application; }

    public User getUser() { return this.user; }

    public Optional<String> getDebugFlagSessionAttributeName() {
        return debugFlagSessionAttributeName;
    }

    Optional<AlexaResponse> fire(final AlexaRequest request) {
        final SpeechletRequestEnvelope envelope = request.getSession().envelope(request);
        String payload = null;
        try {
            payload = mapper.writeValueAsString(envelope);
        } catch (final JsonProcessingException e) {
            final String msg = String.format("Invalid request format. %s", e.getMessage());
            log.error(String.format("→ [ERROR] %s", msg));
            throw new RuntimeException(msg, e);
        }
        return fire(request, payload);
    }

    Optional<AlexaResponse> fire(final AlexaRequest request, final String payload) {
        // ensure payload set
        Validate.notBlank(payload, "[ERROR] Invalid speechlet request contents. Must not be null or empty.");
        // delegate execution to child implementation
        final long startTimestamp = System.currentTimeMillis();
        final Optional<AlexaResponse> response = endpoint.fire(request, payload);
        lastExecutionTimeMillis = System.currentTimeMillis() - startTimestamp;
        response.ifPresent(r -> {
            request.getSession().exploitResponse(r);
            lastResponse = r;
        });
        return response;
    }

    public static AlexaClientBuilder create(final AlexaEndpoint endpoint) {
        return new AlexaClientBuilder(endpoint);
    }

    public static AlexaClientBuilder create(final AlexaEndpoint endpoint, final String applicationId) {
        return create(endpoint).withApplicationId(applicationId);
    }

    public static AlexaClientBuilder create(final InputStream scriptInputStream) throws IOException {
        final YamlReader yamlReader = new YamlReader(IOUtils.toString(scriptInputStream));
        return new AlexaClientBuilder(yamlReader);
    }

    public static AlexaClientBuilder create(final String filePath) throws IOException {
        return create(new FileInputStream(filePath));
    }

    public static AlexaClientBuilder create(final File file) throws IOException {
        return create(new FileInputStream(file));
    }

    public AlexaSession startSession() {
        return new AlexaSession(this);
    }

    /**
     * Starts the script that was loaded from a YAML file referenced when AlexaClient was created.
     * If you created this client without giving it an file reference startScript does
     * nothing as there's no script to read from. In this case use startSession
     */
    public void startScript() {
        Validate.notNull(yLaunch, "[ERROR] Could not find Launch node. Add this node to the top level of your YAML script and use it as an entry point for your conversation path.");
        startSession().executeSession(yLaunch);
    }

    public Locale getLocale() {
        return locale;
    }

    public static class AlexaClientBuilder {
        AlexaEndpoint endpoint;
        Object yLaunch;
        String applicationId;
        AlexaClient.API_ENDPOINT apiEndpoint;
        Locale locale;
        private String uid;
        private String accessToken;
        String debugFlagSessionAttributeName;
        String deviceId;
        Device device;
        List<Interface> interfaces = new ArrayList<>();
        Date timestamp;

        AlexaClientBuilder(final AlexaEndpoint endpoint) {
            this.endpoint = endpoint;
        }

        AlexaClientBuilder(final YamlReader root) {
            HashMap<Object, Object> yRoot = null;
            try {
                yRoot = (HashMap)root.read();
            } catch (YamlException e) {
                log.error("[ERROR] Could not read YAML script file", e);
            }

            final HashMap yConfig = Optional.ofNullable(yRoot.get("configuration")).filter(o -> o instanceof HashMap).map(o -> (HashMap)o).orElseThrow(() -> new RuntimeException("configuration node is missing or empty."));
            final HashMap yEndpoint = Optional.ofNullable(yConfig.get("endpoint")).filter(o -> o instanceof HashMap).map(o -> (HashMap)o).orElseThrow(() -> new RuntimeException("endpoint node is missing or empty."));

            this.endpoint = AlexaEndpointFactory.createEndpoint(yEndpoint);
            this.applicationId = Optional.ofNullable(yEndpoint.get("skillId")).filter(o -> o instanceof String).map(Object::toString).orElse(System.getenv("skillId"));
            this.locale = Locale.forLanguageTag(Optional.ofNullable(yEndpoint.get("locale")).filter(o -> o instanceof String).map(Object::toString).orElse("en-US"));
            this.apiEndpoint = Optional.ofNullable(yEndpoint.get("region")).filter(o -> o instanceof String).map(o -> AlexaClient.API_ENDPOINT.valueOf(o.toString())).orElse(AlexaClient.API_ENDPOINT.NA);
            this.debugFlagSessionAttributeName = Optional.ofNullable(yConfig.get("debugFlagSessionAttributeName")).filter(o -> o instanceof String).map(Object::toString).orElse(null);

            Optional.ofNullable(yConfig.get("device")).filter(o -> o instanceof HashMap).map(o -> (HashMap)o).ifPresent(yDevice -> {
                this.deviceId = Optional.ofNullable(yDevice.get("id")).map(Object::toString).orElse(System.getenv("skillDeviceId"));

                Optional.ofNullable(yDevice.get("supportedInterfaces")).filter(o -> o instanceof ArrayList).map(o -> (ArrayList)o).ifPresent(yInterfaces -> {
                    yInterfaces.forEach(yInterface -> {
                        final String interfaceName = yInterface.toString();
                        if ("Display".equals(interfaceName)) {
                            withSupportedInterface(DisplayInterface.builder().build());
                        }
                        if ("AudioPlayer".equals(interfaceName)) {
                            withSupportedInterface(AudioPlayerInterface.builder().build());
                        }
                    });
                });
            });

            Optional.ofNullable(yConfig.get("user")).filter(o -> o instanceof HashMap).map(o -> (HashMap)o).ifPresent(yUser -> {
                this.uid = Optional.ofNullable(yUser.get("id")).map(Object::toString).orElse(System.getenv("skillUserId"));
                this.accessToken = Optional.ofNullable(yUser.get("accessToken")).map(Object::toString).orElse(System.getenv("skillAccessToken"));
            });

            yLaunch = Optional.ofNullable(yRoot.get("Launch")).orElseThrow(() -> new RuntimeException("There's no 'Launch'-node provided in the YAML script. Create a top-level node named 'Launch' as it is the entry point for the conversation you'd like to simulate."));
        }

        public AlexaClientBuilder withEndpoint(final AlexaEndpoint endpoint) {
            this.endpoint = endpoint;
            return this;
        }

        public AlexaClientBuilder withApiEndpoint(final AlexaClient.API_ENDPOINT apiEndpoint) {
            this.apiEndpoint = apiEndpoint;
            return this;
        }

        public AlexaClientBuilder withApplicationId(final String applicationId) {
            this.applicationId = applicationId;
            return this;
        }

        public AlexaClientBuilder withLocale(final Locale locale) {
            this.locale = locale;
            return this;
        }

        public AlexaClientBuilder withDeviceId(final String deviceId) {
            this.deviceId = deviceId;
            return this;
        }

        public AlexaClientBuilder withDeviceIdRandomized() {
            this.deviceId = UUID.randomUUID().toString();
            return this;
        }

        public AlexaClientBuilder withSupportedInterface(final Interface supportedInterface) {
            if (!interfaces.contains(supportedInterface)) {
                interfaces.add(supportedInterface);
            }
            return this;
        }

        public AlexaClientBuilder withDevice(final Device device) {
            this.device = device;
            return this;
        }

        public AlexaClientBuilder withLocale(final String languageTag) {
            if (!StringUtils.isNullOrEmpty(languageTag)) {
                this.locale = Locale.forLanguageTag(languageTag);
            }
            return this;
        }

        public AlexaClientBuilder withUserId(final String uid) {
            this.uid = uid;
            return this;
        }

        public AlexaClientBuilder withAccessToken(final String accessToken) {
            this.accessToken = accessToken;
            return this;
        }

        public AlexaClientBuilder withTimestamp(final Date timestamp) {
            this.timestamp = timestamp;
            return this;
        }

        public AlexaClientBuilder withDebugFlagSessionAttribute(final String debugFlagSessionAttributeName) {
            this.debugFlagSessionAttributeName = debugFlagSessionAttributeName;
            return this;
        }

        void preBuild() {
            Validate.notNull(endpoint, "Endpoint must not be null.");

            if (StringUtils.isNullOrEmpty(applicationId)) {
                applicationId = generateApplicationId();
            }

            if (locale == null) {
                locale = Locale.US;
            }
            if (StringUtils.isNullOrEmpty(uid)) {
                uid = generateUserId();
            }
            if (timestamp == null) {
                timestamp = new Date();
            }

            if (device == null) {
                SupportedInterfaces supportedInterfaces = null;
                if (!interfaces.isEmpty()) {
                    final SupportedInterfaces.Builder supportedInterfacesBuilder = SupportedInterfaces.builder();
                    interfaces.forEach(supportedInterfacesBuilder::addSupportedInterface);
                    supportedInterfaces = supportedInterfacesBuilder.build();
                } else {
                    supportedInterfaces = SupportedInterfaces.builder().build();
                }
                device = Device.builder().withSupportedInterfaces(supportedInterfaces).withDeviceId(deviceId).build();
            }
        }

        public AlexaClient build() {
            preBuild();
            return new AlexaClient(this);
        }
    }
}