/*
 * Copyright (c) 2019 ETH Zürich, Educational Development and Technology (LET)
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */

package ch.ethz.seb.sebserver;

import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Properties;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.http.OAuth2ErrorHandler;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.model.session.RunningExamInfo;
import ch.ethz.seb.sebserver.gbl.util.Utils;

public class HTTPClientBot {

    private static final long ONE_SECOND = 1000; // milliseconds
    private static final long TEN_SECONDS = 10 * ONE_SECOND;
    private static final long ONE_MINUTE = 60 * ONE_SECOND;
    @SuppressWarnings("unused")
    private static final long ONE_HOUR = 60 * ONE_MINUTE;

    private static final Logger log = LoggerFactory.getLogger(HTTPClientBot.class);

    private final ExecutorService executorService = Executors.newFixedThreadPool(20);

    private final List<String> scopes = Arrays.asList("read", "write");

    private final String webserviceAddress;
    private final String accessTokenEndpoint;
    private final String clientId;
    private final String clientSecret;
    private final String apiPath;
    private final String apiVersion;
    private final String examId;
    private final String institutionId;

    private final int numberOfConnections;

    private final long establishDelay;
    private final long pingInterval;
    private final long pingPause;
    private final long pingPauseDelay;
    private final long errorInterval;
    private final long warnInterval;
    private final long runtime;
    private final int connectionAttempts;

    private final Random random = new Random();

    public HTTPClientBot(final Properties properties) {

        //this.webserviceAddress = properties.getProperty("webserviceAddress", "http://ralph.ethz.ch:8080");
        this.webserviceAddress = properties.getProperty("webserviceAddress", "http://localhost:8080");
        //this.webserviceAddress = properties.getProperty("webserviceAddress", "https://seb.test-swissmooc.ch");

        this.accessTokenEndpoint = properties.getProperty("accessTokenEndpoint", "/oauth/token");
        this.clientId = properties.getProperty("clientId", "test");
        this.clientSecret = properties.getProperty("clientSecret", "test");
//
//        this.clientId = properties.getProperty("clientId", "B48_1~UnU3c0K]0c");
//        this.clientSecret =
//                properties.getProperty("clientSecret",
//                        "io0]PRpzUNVWv*Bg^B=@4!jH$r^Y$CmgheETsd}4ewIXEMq~XPHszIr+$a`S$z[(");
        this.apiPath = properties.getProperty("apiPath", "/exam-api");
        this.apiVersion = properties.getProperty("apiVersion", "v1");
//        this.examId = properties.getProperty("examId", "2");
//        this.institutionId = properties.getProperty("institutionId", "1");
        this.examId = properties.getProperty("examId", "2");
        this.institutionId = properties.getProperty("institutionId", "1");
        this.numberOfConnections = Integer.parseInt(properties.getProperty("numberOfConnections", "100"));
        this.pingInterval = Long.parseLong(properties.getProperty("pingInterval", "200"));
        this.establishDelay = Long.parseLong(properties.getProperty("establishDelay", "0"));
        this.pingPause = Long.parseLong(properties.getProperty("pingPause", "20000"));
        this.pingPauseDelay = Long.parseLong(properties.getProperty("pingPauseDelay", "5000"));
        this.errorInterval = Long.parseLong(properties.getProperty("errorInterval", String.valueOf(TEN_SECONDS)));
        this.warnInterval = Long.parseLong(properties.getProperty("errorInterval", String.valueOf(TEN_SECONDS / 2)));
//        this.runtime = Long.parseLong(properties.getProperty("runtime", String.valueOf(ONE_MINUTE)));
        this.runtime = Long.parseLong(properties.getProperty("runtime", String.valueOf(ONE_MINUTE)));
        this.connectionAttempts = Integer.parseInt(properties.getProperty("connectionAttempts", "1"));

        for (int i = 0; i < this.numberOfConnections; i++) {
            this.executorService.execute(new ConnectionBot("connection_" + getRandomName()));
        }

        this.executorService.shutdown();
    }

    private String getRandomName() {
        final StringBuilder sb = new StringBuilder(String.valueOf(this.random.nextInt(100)));
        while (sb.length() < 3) {
            sb.insert(0, "0");
        }
        return sb.toString();
    }

    public static void main(final String[] args) {
        new HTTPClientBot(System.getProperties());
    }

    private final class ConnectionBot implements Runnable {

        private final String name;
        private final OAuth2RestTemplate restTemplate;

        private final String handshakeURI = HTTPClientBot.this.webserviceAddress +
                HTTPClientBot.this.apiPath + "/" +
                HTTPClientBot.this.apiVersion + API.EXAM_API_HANDSHAKE_ENDPOINT;
        private final String configurartionURI = HTTPClientBot.this.webserviceAddress +
                HTTPClientBot.this.apiPath + "/" +
                HTTPClientBot.this.apiVersion + API.EXAM_API_CONFIGURATION_REQUEST_ENDPOINT;
        private final String pingURI = HTTPClientBot.this.webserviceAddress +
                HTTPClientBot.this.apiPath + "/" +
                HTTPClientBot.this.apiVersion + API.EXAM_API_PING_ENDPOINT;
        private final String eventURI = HTTPClientBot.this.webserviceAddress +
                HTTPClientBot.this.apiPath + "/" +
                HTTPClientBot.this.apiVersion + API.EXAM_API_EVENT_ENDPOINT;

        private final HttpEntity<?> connectBody;

        protected ConnectionBot(final String name) {
            this.name = name;
            this.restTemplate = createRestTemplate(null);
            final MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
            headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
            this.connectBody = new HttpEntity<>(API.PARAM_INSTITUTION_ID +
                    Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR +
                    HTTPClientBot.this.institutionId
//                    + Constants.FORM_URL_ENCODED_SEPARATOR
//                    + API.EXAM_API_PARAM_EXAM_ID
//                    + Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR
//                    + HTTPClientBot.this.examId
                    ,
                    headers);

        }

        @Override
        public void run() {
            log.info("ConnectionBot {} : Client-Connection-Bot started: {}\n"
                    + "webserviceAddress: {}\n"
                    + "accessTokenEndpoint: {}\n"
                    + "clientId: {}\n"
                    + "clientSecret: {}\n"
                    + "apiPath: {}\n"
                    + "apiVersion: {}\n"
                    + "examId: {}\n"
                    + "institutionId: {}\n"
                    + "pingInterval: {}\n"
                    + "errorInterval: {}\n"
                    + "runtime: {}\n", this.name,
                    HTTPClientBot.this.webserviceAddress,
                    HTTPClientBot.this.accessTokenEndpoint,
                    HTTPClientBot.this.clientId,
                    HTTPClientBot.this.clientSecret,
                    HTTPClientBot.this.apiPath,
                    HTTPClientBot.this.apiVersion,
                    HTTPClientBot.this.examId,
                    HTTPClientBot.this.institutionId,
                    HTTPClientBot.this.pingInterval,
                    HTTPClientBot.this.errorInterval);

            int attempt = 0;
            String connectionToken = null;

            while (connectionToken == null && attempt < HTTPClientBot.this.connectionAttempts) {
                attempt++;
                log.info("ConnectionBot {} : Try to request access-token; attempt: {}", this.name, attempt);
                try {

                    final OAuth2AccessToken accessToken = this.restTemplate.getAccessToken();
                    log.info("ConnectionBot {} : Got access token: {}", this.name, accessToken);
                    connectionToken = createConnection();

                } catch (final Exception e) {
                    log.error("ConnectionBot {} : Failed to request access-token: ", this.name, e);
                    if (attempt >= HTTPClientBot.this.connectionAttempts) {
                        log.error("ConnectionBot {} : Gave up afer {} connection attempts: ", this.name, attempt);
                        return;
                    }
                }
            }

            final MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
            headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
            headers.set(API.EXAM_API_SEB_CONNECTION_TOKEN, connectionToken);

            final MultiValueMap<String, String> eventHeaders = new LinkedMultiValueMap<>();
            eventHeaders.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE);
            eventHeaders.set(API.EXAM_API_SEB_CONNECTION_TOKEN, connectionToken);

            if (connectionToken != null) {
                if (getConfig(headers) && establishConnection(headers)) {

                    final PingEntity pingHeader = new PingEntity(headers);
                    final EventEntity errorHeader = new EventEntity(eventHeaders, "ERROR_LOG");
                    final EventEntity warnHeader = new EventEntity(eventHeaders, "WARN_LOG");

                    try {
                        final long startTime = System.currentTimeMillis();
                        final long endTime = startTime + HTTPClientBot.this.runtime;
                        final long pingPauseStart = startTime + HTTPClientBot.this.pingPauseDelay;
                        final long pingPauseEnd = pingPauseStart + HTTPClientBot.this.pingPause;
                        long currentTime = startTime;
                        long lastPingTime = startTime;
                        long lastErrorTime = startTime;
                        long lastWarnTime = startTime;

                        while (currentTime < endTime) {
                            if (currentTime - lastPingTime >= HTTPClientBot.this.pingInterval &&
                                    !(currentTime > pingPauseStart && currentTime < pingPauseEnd)) {

                                pingHeader.next();
                                if (!sendPing(pingHeader)) {
                                    // expecting a quit instruction was sent here
                                    return;
                                }
                                lastPingTime = currentTime;
                            }
                            if (currentTime - lastErrorTime >= HTTPClientBot.this.errorInterval) {
                                errorHeader.next();
                                sendEvent(errorHeader);
                                lastErrorTime = currentTime;
                            }
                            if (currentTime - lastWarnTime >= HTTPClientBot.this.warnInterval) {
                                warnHeader.next();
                                sendEvent(warnHeader);
                                lastWarnTime = currentTime;
                            }
                            try {
                                Thread.sleep(50);
                            } catch (final Exception e) {
                            }
                            currentTime = System.currentTimeMillis();
                        }
                    } catch (final Throwable t) {
                        log.error("ConnectionBot {} : Error sending events: ", this.name, t);
                    } finally {
                        disconnect(connectionToken);
                    }
                }
            }
        }

        private String createConnection() {
            log.info("ConnectionBot {} : init connection", this.name);

            try {
                final ResponseEntity<Collection<RunningExamInfo>> exchange = this.restTemplate.exchange(
                        this.handshakeURI,
                        HttpMethod.POST,
                        this.connectBody,
                        new ParameterizedTypeReference<Collection<RunningExamInfo>>() {
                        });

                final HttpStatus statusCode = exchange.getStatusCode();
                if (statusCode.isError()) {
                    throw new RuntimeException("Webservice answered with error: " + exchange.getBody());
                }

                final Collection<RunningExamInfo> body = exchange.getBody();
                final String token = exchange.getHeaders().getFirst(API.EXAM_API_SEB_CONNECTION_TOKEN);

                log.info("ConnectionBot {} : successfully created connection, token: {} body: {} ", this.name, token,
                        body);

                return token;
            } catch (final Exception e) {
                log.error("ConnectionBot {} : Failed to init connection", this.name, e);
                return null;
            }
        }

        public boolean getConfig(final MultiValueMap<String, String> headers) {
            final HttpEntity<?> configHeader = new HttpEntity<>(headers);

            log.info("ConnectionBot {} : get SEB Configuration", this.name);

            try {
                final ResponseEntity<byte[]> exchange = this.restTemplate.exchange(
                        this.configurartionURI + "?" + API.EXAM_API_PARAM_EXAM_ID +
                                Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR +
                                HTTPClientBot.this.examId,
                        HttpMethod.GET,
                        configHeader,
                        new ParameterizedTypeReference<byte[]>() {
                        });

                final HttpStatus statusCode = exchange.getStatusCode();
                if (statusCode.isError()) {
                    throw new RuntimeException("Webservice answered with error: " + exchange.getBody());
                }

                final byte[] config = exchange.getBody();

                if (ArrayUtils.isEmpty(config)) {
                    log.error("No Exam config get from API. processing anyway");
                }

                if (log.isDebugEnabled()) {
                    log.debug("ConnectionBot {} : successfully requested exam config: " + Utils.toString(config),
                            this.name);
                } else {
                    log.info("ConnectionBot {} : successfully requested exam config", this.name);
                }

                return true;
            } catch (final Exception e) {
                log.error("ConnectionBot {} : Failed get SEB Configuration", this.name, e);
                return false;
            }
        }

        public boolean establishConnection(final MultiValueMap<String, String> headers) {

            if (HTTPClientBot.this.establishDelay > 0) {
                try {

                    log.info("Wait for connection activation -> {}", HTTPClientBot.this.establishDelay);

                    Thread.sleep(HTTPClientBot.this.establishDelay);
                } catch (final Exception e) {
                    log.error("Failed to wait for connection activiation -> {} : {}", HTTPClientBot.this.establishDelay,
                            e.getMessage());
                }
            }

            final HttpEntity<?> configHeader = new HttpEntity<>(
                    API.EXAM_API_USER_SESSION_ID +
                            Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR +
                            this.name,
                    headers);

            log.info("ConnectionBot {} : Trying to establish SEB client connection", this.name);

            try {

                final ResponseEntity<String> exchange = this.restTemplate.exchange(
                        this.handshakeURI,
                        HttpMethod.PUT,
                        configHeader,
                        new ParameterizedTypeReference<String>() {
                        });

                final HttpStatus statusCode = exchange.getStatusCode();
                if (statusCode.isError()) {
                    throw new RuntimeException("Webservice answered with error: " + exchange.getBody());
                }

                log.info("ConnectionBot {} : successfully established SEB client connection", this.name);

                return true;
            } catch (final Exception e) {
                log.error("ConnectionBot {} : Failed get established SEB client connection", this.name, e);
                return false;
            }
        }

        private boolean sendPing(final HttpEntity<String> pingHeader) {
            try {

                final ResponseEntity<String> exchange = this.restTemplate.exchange(
                        this.pingURI,
                        HttpMethod.POST,
                        pingHeader,
                        new ParameterizedTypeReference<String>() {
                        });

                if (exchange.hasBody() && exchange.getBody().contains("SEB_QUIT")) {
                    return false;
                }

                return true;
            } catch (final Exception e) {
                log.error("ConnectionBot {} : Failed send ping", this.name, e);
                return false;
            }
        }

        private boolean sendEvent(final HttpEntity<String> eventHeader) {
            try {

                this.restTemplate.exchange(
                        this.eventURI,
                        HttpMethod.POST,
                        eventHeader,
                        new ParameterizedTypeReference<String>() {
                        });

                return true;
            } catch (final Exception e) {
                log.error("ConnectionBot {} : Failed send event", this.name, e);
                return false;
            }
        }

        public boolean disconnect(final String connectionToken) {
            final MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
            headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
            headers.set(API.EXAM_API_SEB_CONNECTION_TOKEN, connectionToken);
            final HttpEntity<?> configHeader = new HttpEntity<>(headers);

            log.info("ConnectionBot {} : Trying to delete SEB client connection", this.name);

            try {

                final ResponseEntity<String> exchange = this.restTemplate.exchange(
                        this.handshakeURI,
                        HttpMethod.DELETE,
                        configHeader,
                        new ParameterizedTypeReference<String>() {
                        });

                final HttpStatus statusCode = exchange.getStatusCode();
                if (statusCode.isError()) {
                    throw new RuntimeException("Webservice answered with error: " + exchange.getBody());
                }

                log.info("ConnectionBot {} : successfully deleted SEB client connection", this.name);

                return true;
            } catch (final Exception e) {
                log.error("ConnectionBot {} : Failed get deleted SEB client connection", this.name, e);
                return false;
            }
        }
    }

    private OAuth2RestTemplate createRestTemplate(final String scopes) {
        final ClientCredentialsResourceDetails clientCredentialsResourceDetails =
                new ClientCredentialsResourceDetails();
        clientCredentialsResourceDetails.setAccessTokenUri(this.webserviceAddress + this.accessTokenEndpoint);
        clientCredentialsResourceDetails.setClientId(this.clientId);
        clientCredentialsResourceDetails.setClientSecret(this.clientSecret);
        if (StringUtils.isBlank(scopes)) {
            clientCredentialsResourceDetails.setScope(this.scopes);
        } else {
            clientCredentialsResourceDetails.setScope(
                    Arrays.asList(StringUtils.split(scopes, Constants.LIST_SEPARATOR)));
        }

        final OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(clientCredentialsResourceDetails);
        restTemplate.setErrorHandler(new OAuth2ErrorHandler(clientCredentialsResourceDetails) {

            @Override
            public void handleError(final ClientHttpResponse response) throws IOException {
                System.out.println("********************** handleError: " + response.getStatusCode());
                super.handleError(response);
            }

        });
        restTemplate
                .getMessageConverters()
                .add(0, new StringHttpMessageConverter(Charset.forName("UTF-8")));
        //restTemplate.setRetryBadAccessTokens(true);

        final SimpleClientHttpRequestFactory simpleClientHttpRequestFactory = new SimpleClientHttpRequestFactory();
        simpleClientHttpRequestFactory.setReadTimeout(5000);
        simpleClientHttpRequestFactory.setOutputStreaming(false);
        restTemplate.setRequestFactory(simpleClientHttpRequestFactory);

        return restTemplate;
    }

    private static class PingEntity extends HttpEntity<String> {
        private final String pingBodyTemplate = API.EXAM_API_PING_TIMESTAMP +
                Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR +
                "%s" +
                Constants.FORM_URL_ENCODED_SEPARATOR +
                API.EXAM_API_PING_NUMBER +
                Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR +
                "%s";

        private long timestamp = 0;
        private int count = 0;

        protected PingEntity(final MultiValueMap<String, String> headers) {
            super(headers);
        }

        void next() {
            this.timestamp = System.currentTimeMillis();
            this.count++;
        }

        @Override
        public String getBody() {
            return String.format(this.pingBodyTemplate, this.timestamp, this.count);
        }

        @Override
        public boolean hasBody() {
            return true;
        }
    }

    private static class EventEntity extends HttpEntity<String> {
        private final String eventBodyTemplate =
                "{ \"type\": \"%s\", \"timestamp\": %s, \"text\": \"some error " + UUID.randomUUID() + " \" }";

        private long timestamp = 0;
        private final String eventType;

        protected EventEntity(final MultiValueMap<String, String> headers, final String eventType) {
            super(headers);
            this.eventType = eventType;
        }

        void next() {
            this.timestamp = System.currentTimeMillis();
        }

        @Override
        public String getBody() {
            return String.format(this.eventBodyTemplate, this.eventType, this.timestamp);
        }

        @Override
        public boolean hasBody() {
            return true;
        }
    }

}
