/*
 * 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.webservice.servicelayer.session.impl;

import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

import ch.ethz.seb.sebserver.SEBServerInit;
import ch.ethz.seb.sebserver.SEBServerInitEvent;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.JSONMapper;
import ch.ethz.seb.sebserver.gbl.model.session.ClientInstruction.InstructionType;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gbl.util.SizedArrayNonBlockingQueue;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.webservice.WebserviceInfo;
import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientInstructionRecord;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ClientConnectionDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ClientInstructionDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.SEBClientInstructionService;

@Lazy
@Service
@WebServiceProfile
public class SEBClientInstructionServiceImpl implements SEBClientInstructionService {

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

    private static final int INSTRUCTION_QUEUE_MAX_SIZE = 10;
    private static final String JSON_INST = "instruction";
    private static final String JSON_ATTR = "attributes";

    private final WebserviceInfo webserviceInfo;
    private final ClientConnectionDAO clientConnectionDAO;
    private final ClientInstructionDAO clientInstructionDAO;
    private final JSONMapper jsonMapper;

    private final Map<String, SizedArrayNonBlockingQueue<ClientInstructionRecord>> instructions;

    private long lastRefresh = 0;

    public SEBClientInstructionServiceImpl(
            final WebserviceInfo webserviceInfo,
            final ClientConnectionDAO clientConnectionDAO,
            final ClientInstructionDAO clientInstructionDAO,
            final JSONMapper jsonMapper) {

        this.webserviceInfo = webserviceInfo;
        this.clientConnectionDAO = clientConnectionDAO;
        this.clientInstructionDAO = clientInstructionDAO;
        this.jsonMapper = jsonMapper;
        this.instructions = new ConcurrentHashMap<>();
    }

    @Override
    public WebserviceInfo getWebserviceInfo() {
        return this.webserviceInfo;
    }

    @EventListener(SEBServerInitEvent.class)
    public void init() {
        SEBServerInit.INIT_LOGGER.info("------>");
        SEBServerInit.INIT_LOGGER.info("------> Run SEBInstructionService...");

        loadInstructions()
                .onError(
                        error -> log.error(
                                "Failed  to initialize and load persistent storage SEB client instructions: ",
                                error));

        if (this.instructions.size() > 0) {
            SEBServerInit.INIT_LOGGER.info("------> Loaded {} SEB client instructions from persistent storage",
                    this.instructions.size());
        } else {
            SEBServerInit.INIT_LOGGER.info("------> No pending SEB client instructions found on persistent storage");
        }
    }

    @Override
    public Result<Void> registerInstruction(
            final Long examId,
            final InstructionType type,
            final Map<String, String> attributes,
            final String connectionToken,
            final boolean needsConfirm) {

        return Result.tryCatch(() -> {

            final boolean isActive = this.clientConnectionDAO
                    .isActiveConnection(examId, connectionToken)
                    .getOr(false);

            if (isActive) {
                try {
                    final String attributesString = this.jsonMapper.writeValueAsString(attributes);
                    this.clientInstructionDAO
                            .insert(examId, type, attributesString, connectionToken, needsConfirm)
                            .map(this::putToCacheIfAbsent)
                            .onError(error -> log.error("Failed to register instruction: {}", error.getMessage()))
                            .getOrThrow();
                } catch (final Exception e) {
                    throw new RuntimeException("Unexpected: ", e);
                }
            }
        });
    }

    @Override
    public Result<Void> registerInstruction(
            final Long examId,
            final InstructionType type,
            final Map<String, String> attributes,
            final Set<String> connectionTokens,
            final boolean needsConfirm) {

        return Result.tryCatch(() -> {

            final String attributesString = Utils.toJsonObject(attributes);
            final Set<String> activeConnections = this.clientConnectionDAO
                    .filterActive(examId, connectionTokens)
                    .getOrElse(Collections::emptySet);

            connectionTokens
                    .stream()
                    .filter(activeConnections::contains)
                    .map(token -> this.clientInstructionDAO.insert(examId, type, attributesString, token, needsConfirm))
                    .map(result -> result.get(
                            error -> log.error("Failed to register instruction: {}", error.getMessage()),
                            () -> null))
                    .filter(Objects::nonNull)
                    .forEach(this::putToCacheIfAbsent);
        });

    }

    @Override
    public String getInstructionJSON(final String connectionToken) {
        refreshCache();
        if (this.instructions.isEmpty()) {
            return null;
        }

        if (!this.instructions.containsKey(connectionToken)) {
            return null;
        }

        final SizedArrayNonBlockingQueue<ClientInstructionRecord> queue = this.instructions.get(connectionToken);
        if (queue.isEmpty()) {
            return null;
        }

        // Remove the head instruction from the queue
        final ClientInstructionRecord clientInstruction = queue.poll();
        if (clientInstruction == null) {
            return null;
        }

        final boolean needsConfirm = BooleanUtils.toBoolean(clientInstruction.getNeedsConfirmation());
        if (needsConfirm) {
            // add the instruction back to the queue's tail if it need a confirmation
            queue.add(clientInstruction);

        } else {
            // otherwise remove it also from the persistent storage
            final Result<Void> delete = this.clientInstructionDAO.delete(clientInstruction.getId());
            if (delete.hasError()) {
                log.error("Failed to delete SEB client instruction on persistent storage: ", delete.getError());
            }
        }

        // {"instruction":"%s", "attributes":%s}
        final String attributes = clientInstruction.getAttributes();
        final StringBuilder sBuilder = new StringBuilder()
                .append(Constants.CURLY_BRACE_OPEN)
                .append(Constants.DOUBLE_QUOTE)
                .append(JSON_INST)
                .append(Constants.DOUBLE_QUOTE)
                .append(Constants.COLON)
                .append(Constants.DOUBLE_QUOTE)
                .append(clientInstruction.getType())
                .append(Constants.DOUBLE_QUOTE);

        if (StringUtils.isNotBlank(attributes)) {
            sBuilder.append(Constants.COMMA)
                    .append(Constants.DOUBLE_QUOTE)
                    .append(JSON_ATTR)
                    .append(Constants.DOUBLE_QUOTE)
                    .append(Constants.COLON)
                    .append(attributes);
        }

        final String instructionJSON = sBuilder
                .append(Constants.CURLY_BRACE_CLOSE)
                .toString();

        if (log.isDebugEnabled()) {
            log.debug("Send SEB client instruction: {} to: {} ", connectionToken, instructionJSON);
        }

        return instructionJSON;
    }

    @Override
    public void confirmInstructionDone(final String connectionToken, final String instructionConfirm) {
        try {
            final SizedArrayNonBlockingQueue<ClientInstructionRecord> queue = this.instructions.get(connectionToken);
            if (queue.isEmpty()) {
                return;
            }

            final Long instructionId = Long.valueOf(instructionConfirm);
            if (queue.removeIf(instruction -> instructionId.equals(instruction.getId()))) {
                this.clientInstructionDAO.delete(instructionId);
            } else {
                log.warn("SEB instruction confirmation mismatch. No pending instruction found for id: {}",
                        instructionConfirm);
            }
        } catch (final Exception e) {
            log.error(
                    "Failed to remove SEB instruction after confirmation: connectionToken: {} instructionConfirm: {} connectionToken: {}",
                    connectionToken,
                    instructionConfirm,
                    connectionToken);
        }
    }

    private void refreshCache() {
        if (!this.webserviceInfo.isDistributed()) {
            return;
        }

        final long currentTimeMillis = System.currentTimeMillis();
        if (currentTimeMillis - this.lastRefresh > Constants.SECOND_IN_MILLIS) {
            this.lastRefresh = currentTimeMillis;

            loadInstructions()
                    .onError(error -> log.error(
                            "Failed load instructions from persistent storage and to refresh cache: ",
                            error));
        }
    }

    private Result<Void> loadInstructions() {
        return Result.tryCatch(() -> this.clientInstructionDAO.getAllActive()
                .getOrThrow()
                .forEach(this::putToCacheIfAbsent));
    }

    private ClientInstructionRecord putToCacheIfAbsent(final ClientInstructionRecord instruction) {
        final SizedArrayNonBlockingQueue<ClientInstructionRecord> queue = this.instructions.computeIfAbsent(
                instruction.getConnectionToken(),
                key -> new SizedArrayNonBlockingQueue<>(INSTRUCTION_QUEUE_MAX_SIZE));

        if (queue.contains(instruction)) {
            log.warn("Instruction alread in the queue: {}", instruction);
            return instruction;
        }

        if (log.isDebugEnabled()) {
            log.debug("Put SEB instruction into instruction queue: {}", instruction);
        }

        queue.add(instruction);
        return instruction;
    }

}
