package cz.senslog.connector.push.rest.senslog.v1;

import com.google.gson.reflect.TypeToken;
import cz.senslog.connector.tools.http.*;
import cz.senslog.connector.model.v1.Observation;
import cz.senslog.connector.model.v1.Position;
import cz.senslog.connector.model.v1.Record;
import cz.senslog.connector.model.v1.SenslogV1Model;
import cz.senslog.connector.push.api.ConnectorPusher;
import cz.senslog.connector.tools.util.Tuple;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.lang.reflect.Type;
import java.util.*;
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

import static cz.senslog.connector.tools.http.HttpContentType.TEXT_PLAIN;
import static cz.senslog.connector.tools.json.BasicJson.jsonToObject;
import static java.util.Collections.emptyList;

/**
 * The class {@code SenslogV1Pusher} represents an implementation of {@link ConnectorPusher}.
 * The class receive a {@link SenslogV1Model} which contains data to send.
 *
 * <h2>Initialization</h2>
 *  - nothing to initialize
 *
 * <h2>Push</h2>
 * If the model received does not exist or does not contain any observation, nothing will be sent.
 * For each observation is created a request which is send according to configuration.
 * The observation that can not be sent (response contains error) is moved to be sent later.
 * If more than the number of fails requests fail, the sending will over.
 *
 * @author Lukas Cerny
 * @version 1.0
 * @since 1.0
 */
class  SenslogV1Pusher implements ConnectorPusher<SenslogV1Model> {

    private static final Logger logger = LogManager.getLogger(SenslogV1Pusher.class);

    private static final int MAX_AUTH_ERRORS = 2;

    /** Configuration. */
    private final SenslogV1Config config;

    /** Http client for requests. */
    private final HttpClient httpClient;

    /** Queue of observations wait to be send. */
    private final Queue<Record> observationQueue;

    /** List of failed observations. */
    private final List<Record> failedObservations;

    private Tuple<Boolean, HttpCookie> authCookie;
    private final Map<Long, AtomicInteger> authError;

    /**
     * Constructor of the class sets all attributes.
     * @param config - configuration for pusher.
     * @param httpClient - http client.
     */
    SenslogV1Pusher(SenslogV1Config config, HttpClient httpClient) {
        this.config = config;
        this.httpClient = httpClient;
        this.observationQueue = new LinkedTransferQueue<>();
        this.failedObservations = new ArrayList<>();
        this.authCookie = Tuple.of(false, null);
        this.authError = new HashMap<>();
    }

    @Override
    public void init() {}

    private synchronized HttpCookie getAuthCookie() {
        if (authCookie.getItem1()) {
            return authCookie.getItem2();
        }

        if (config.getAuth() == null) {
            authCookie = Tuple.of(true, null);
            return authCookie.getItem2();
        }

        HttpRequest request = HttpRequest.newBuilder().GET()
                .url(URLBuilder.newBuilder(config.getBaseUrl(), "/ControllerServlet")
                        .addParam("username", config.getAuth().getUsername())
                        .addParam("password", config.getAuth().getPassword())
                        .build()
                ).build();
        logger.info("Getting new auth cookie from the server: {}.", config.getBaseUrl());

        HttpResponse response = httpClient.send(request);
        logger.info("Received new auth token with the status '{}' from the server {}.", response.getStatus(), config.getBaseUrl());

        if (response.isError()) {
            logger.warn("Authorization failed. Error code {} with the reason '{}'.", response.getStatus(), response.getBody());
            HttpCookie cookie = HttpCookie.empty();
            authCookie = Tuple.of(false, cookie);
            return cookie;
        }

        final Type lastObsType = new TypeToken<Map<String, Object>>() {}.getType();
        Map<String, Object> jsonResponse = jsonToObject(response.getBody(), lastObsType);

        if (!jsonResponse.containsKey("sessionid")) {
            logger.error("Authorization failed. JSON does not contain session id. {}", response.getBody());
            HttpCookie cookie = HttpCookie.empty();
            authCookie = Tuple.of(false, cookie);
            return cookie;
        }

        String sessionId = (String) jsonResponse.get("sessionid");
        String domain = config.getBaseURI().getHost();
        String path = config.getBaseURI().getPath();
        HttpCookie cookie = new HttpCookie("JSESSIONID", sessionId, domain, path);
        authCookie = Tuple.of(true, cookie);
        return cookie;
    }

    @Override
    public void push(SenslogV1Model model) {
        if (model == null) { return; }

        if (model.getObservations() != null) {

            List<Record> observations = model.getObservations();
            logger.info("Received {} new observations from {} to {} to push.",
                    observations.size(), model.getFrom(), model.getTo());

            logger.debug("Adding all new observations to the queue.");
            observationQueue.addAll(observations);
        }

        if (!failedObservations.isEmpty()) {
            logger.info("Adding {} failed observations to the queue.", failedObservations.size());
            observationQueue.addAll(failedObservations);
            failedObservations.clear();
        }

        int totalToPush = observationQueue.size();
        int pushedSuccessfully = 0;
        while (!observationQueue.isEmpty()) {
            Record record = observationQueue.remove();

            String id = UUID.randomUUID().toString();
            logger.debug("Observation {} {}", id, record);

            if (!validateRequiredValues(
                    record::getUnitId,
                    record::getTime)
            ) {
                logger.error("Observation {} can not be send because required values are null.", record); continue;
            }

            HttpRequest.Builder reqBuilder = null; // TODO refactor
            if (record instanceof Observation) {
                reqBuilder = prepareRequest((Observation) record, config.getBaseUrl());
            } else if (record instanceof Position) {
                reqBuilder = prepareRequest((Position) record, config.getBaseUrl());
            }

            if (reqBuilder == null) {
                logger.error("Request for the {} was not created.", id); continue;
            } else {
                logger.debug("Request for the {} was created successfully.", id);
            }

            String responseBody = sendRequest(reqBuilder, id);
            logger.debug("Parsing body of the response.");
            boolean result = Boolean.parseBoolean(responseBody);

            if (result) {
                pushedSuccessfully += 1;
            } else {
                logger.warn("Observation {} was rejected.", record);
            }
        }
        logger.info("Total pushed successfully {}/{} observations.", pushedSuccessfully, totalToPush);
    }

    private String sendRequest(HttpRequest.Builder reqBuilder, String reqId) {
        HttpCookie authCookie = getAuthCookie();

        HttpRequest request;
        if (authCookie == null) {
            request = reqBuilder.build();
        } else if (authCookie.isNotSecure()) {
            logger.error("Auth cookie is not valid to be used."); return "false";
        } else {
            request = reqBuilder.addCookie(authCookie).build();
        }

        logger.debug("Sending request for the {}", reqId);
        HttpResponse response = httpClient.send(request);
        logger.debug("Response of {} {}", reqId, response);

        long threadId = Thread.currentThread().getId();
        AtomicInteger authErrors = authError.computeIfAbsent(threadId, id -> new AtomicInteger(0));
        if (response.getStatus() == HttpCode.UNAUTHORIZED && authErrors.get() <= MAX_AUTH_ERRORS) {
            this.authCookie = Tuple.of(false, null);
            authErrors.incrementAndGet();
            return sendRequest(reqBuilder, reqId);
        }

        String responseBody = response.getBody();
        if (response.isError()) {
            logger.error("Observation {} was not send. Reason {}.", reqId, responseBody);
        }

        return responseBody;
    }

    private static HttpRequest.Builder prepareRequest(Observation observation, String hostUrl) {
        return HttpRequest.newBuilder()
                .contentType(TEXT_PLAIN)
                .url(URLBuilder.newBuilder(hostUrl, "FeederServlet")
                        .addParam("Operation", "InsertObservation")
                        .addParam("value", observation.getValue())
                        .addParam("date", observation.getFormattedTime())
                        .addParam("unit_id", observation.getUnitId())
                        .addParam("sensor_id", observation.getSensorId())
                        .build())
                .GET();
    }

    private static HttpRequest.Builder prepareRequest(Position position, String hostUrl) {

        if (!validateRequiredValues(
                position::getLatitude,
                position::getLongitude
        )) {
            return null;
        }

        return HttpRequest.newBuilder()
                .contentType(TEXT_PLAIN)
                .url(URLBuilder.newBuilder(hostUrl, "FeederServlet")
                        .addParam("Operation", "InsertPosition")
                        .addParam("date", position.getFormattedTime())
                        .addParam("unit_id", position.getUnitId())
                        .addParam("lat", position.getLatitude())
                        .addParam("lon", position.getLongitude())
                        .addParam("alt", position.getAltitude())
                        .addParam("speed", position.getSpeed())
                        .addParam("dop", position.getDilutionOfPrecision())
                        .build())
                .GET();
    }

    @SafeVarargs
    private static boolean validateRequiredValues(Supplier<Object>... attributes) {
        for (Supplier<Object> attribute : attributes) {
            if (attribute.get() == null) {
                return false;
            }
        }
        return true;
    }
}