package meerkat.voting.controller;

import meerkat.protobuf.Voting.*;
import meerkat.voting.controller.callbacks.*;
import meerkat.voting.controller.commands.*;
import meerkat.voting.controller.selector.QuestionSelector;
import meerkat.voting.controller.selector.SimpleListCategoriesSelector;
import meerkat.voting.encryptor.VBCryptoManager;
import meerkat.voting.encryptor.VBCryptoManager.EncryptionAndSecrets;
import meerkat.voting.output.BallotOutputDevice;
import meerkat.voting.storage.StorageManager;
import meerkat.voting.gui.ui.VotingBoothUI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.security.SignatureException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * An asynchronous implementation of the VotingBoothController.
 * This implementation binds the other components (storage, ui, output device, and crypto manager),
 * and runs as its own thread controlling the whole VB process.
 * The high level details are that it has a queue of commands to handle in order, and a State object which keeps
 * all data from previous tasks which is necessary for the next task.
 * It calls executions in the UI and output device asynchronously.
 */
public class VotingBoothImpl implements VotingBoothController {

    private final Logger logger = LoggerFactory.getLogger(VotingBoothImpl.class);

    // the component interfaces of the Voting Booth
    private BallotOutputDevice outputDevice;
    private VBCryptoManager crypto;
    private VotingBoothUI ui;
    private StorageManager storageManager;

    // election details and info
    private List<BallotQuestion> questionsForChoosingChannel;
    private QuestionSelector questionSelector;
    private Map<String, UIElement> systemMessages;

    // state
    private ControllerState state;
    private volatile boolean shutDownHasBeenCalled;
    private LinkedBlockingQueue<ControllerCommand> queue;
    protected final int MAX_REQUEST_IDENTIFIER = 100000;
    private static int requestCounter = 0;


    // a simple constructor
    public VotingBoothImpl () {
        logger.info("A VotingBoothImpl is constructed");
        shutDownHasBeenCalled = false;
        queue = new LinkedBlockingQueue<>();
        state = new ControllerState();
    }

    @Override
    public void init(BallotOutputDevice outputDevice,
                     VBCryptoManager vbCrypto,
                     VotingBoothUI vbUI,
                     StorageManager vbStorageManager) throws IOException {
        logger.info("init is called");

        // keep pointers to the VB components
        this.outputDevice = outputDevice;
        this.crypto = vbCrypto;
        this.ui = vbUI;
        this.storageManager = vbStorageManager;

        // store election details and info
        ElectionParams electionParams;
        try {
            logger.info("init: reading election params");
            electionParams = storageManager.readElectionParams();
            logger.info("init: reading system messages");
            systemMessages = storageManager.readSystemMessages();
        }
        catch (IOException e) {
            logger.error("init could not read info from a file. Exception is: " + e);
            throw e;
        }

        logger.info("init: setting the election parameters");
        this.questionsForChoosingChannel = electionParams.getChannelChoiceQuestionsList();
        List<BallotQuestion> questions = electionParams.getRaceQuestionsList();
        this.questionSelector = new SimpleListCategoriesSelector(questions, electionParams.getSelectionData());
        logger.info("init: setting finished");
    }


    @Override
    public void run() {
        logger.info("run command has been called");
        runVotingFlow();
        doShutDown();

    }

    /**
     * a method for running the Voting flow of the VB (in contrast to the admin-setup flow
     * It simply loops: takes the next command in its inner queue and handles it
     */
    private void runVotingFlow () {
        logger.info("entered the voting flow");

        queue.add(new RestartVotingCommand(generateRequestIdentifier(), state.currentBallotSerialNumber));

        while (! wasShutDownCalled()) {
            try {
                ControllerCommand Command = queue.take();
                handleSingleCommand(Command);
            }
            catch (InterruptedException e) {
                logger.warn ("Interrupted while reading from command queue " + e);
            }
        }
    }

    @Override
    public void callShutDown() {
        logger.info("callShutDown command has been called");
        shutDownHasBeenCalled = true;
        queue.clear();
        ui.callShutDown();
        outputDevice.callShutDown();
    }

    /**
     * this method decides upon a given command if to ignore it (if it has an old serial number) or to handle it
     * If we choose to handle it, then it simply calls the matching method which handles this type of command
     * @param command a command to handle next (probably from the inner command queue)
     */
    private void handleSingleCommand(ControllerCommand command) {
        // check if the command is old and should be ignored
        if (command.getBallotSerialNumber() != state.currentBallotSerialNumber && !(command instanceof RestartVotingCommand)) {
            // probably an old command relating to some old ballot serial number. Simply log it and ignore it.
            String errorMessage = "handleSingleCommand: received a task too old. " +
                    command.getBallotSerialNumber() + " " + state.currentBallotSerialNumber;
            logger.debug(errorMessage);
            return;
        }

        // decide which method to run according to the command type
        if (command instanceof RestartVotingCommand) {
            doRestartVoting ();
        }
        else if (command instanceof ChannelChoiceCommand) {
            doChooseChannel();
        }
        else if (command instanceof ChannelDeterminedCommand) {
            doSetChannelAndAskQuestions ((ChannelDeterminedCommand)command);
        }
        else if (command instanceof ChooseFinalizeOptionCommand) {
            doChooseFinalizeOption();
        }
        else if (command instanceof CastCommand) {
            doFinalize(false);
        }
        else if (command instanceof AuditCommand) {
            doFinalize(true);
        }
        else if (command instanceof EncryptAndCommitBallotCommand) {
            doCommit ((EncryptAndCommitBallotCommand)command);
        }
        else if (command instanceof ReportErrorCommand) {
            doReportErrorAndForceRestart((ReportErrorCommand)command);
        }
        else {
            logger.error("handleSingleCommand: unknown type of ControllerCommand received: " + command.getClass().getName());
            doReportErrorAndForceRestart(systemMessages.get(StorageManager.SOMETHING_WRONG_MESSAGE));
        }
    }

    private boolean wasShutDownCalled () {
        return shutDownHasBeenCalled;
    }

    private void doShutDown () {
        logger.info("running callShutDown");
        state.clearAndResetState(VBState.SHUT_DOWN);
        //TODO: add commands to actually shut down the machine
    }

    /**
     * a method to execute a Restart Voting Command
     */
    private void doRestartVoting () {
        queue.clear();
        state.clearAndResetState(VBState.NEW_VOTER);
        ui.startNewVoterSession(new NewVoterCallback(generateRequestIdentifier(), state.currentBallotSerialNumber, this.queue));
    }

    /**
     * a (overloaded) method to execute a Report Error Command.
     * It actually just runs the overloaded version of this method with the error message inside the command
     * @param command the command has the info of the error message to report
     */
    private void doReportErrorAndForceRestart(ReportErrorCommand command) {
        doReportErrorAndForceRestart(command.getErrorMessage());
    }

    /**
     * a (overloaded) method to report an error message to the voter
     * @param errorMessage message to show the voter
     */
    private void doReportErrorAndForceRestart(UIElement errorMessage) {
        queue.clear();
        state.clearAndResetState(VBState.FATAL_ERROR_FORCE_NEW_VOTER);
        ui.showErrorMessageWithButtons(errorMessage,
                new UIElement[]{systemMessages.get(StorageManager.RESTART_VOTING_BUTTON)},
                new ErrorMessageRestartCallback(generateRequestIdentifier(),
                        state.currentBallotSerialNumber,
                        this.queue));
    }

    /**
     * a method to execute a Channel Choice Command
     * it notifies the UI to present the channel choice questions to the voter
     */
    private void doChooseChannel () {
        if (state.stateIdentifier == VBState.NEW_VOTER) {
            logger.debug("doing chooseChannel");
            state.stateIdentifier = VBState.CHOOSE_CHANNEL;
            ui.chooseChannel(this.questionsForChoosingChannel,
                    new ChannelChoiceCallback(generateRequestIdentifier(),
                            state.currentBallotSerialNumber,
                            this.queue,
                            systemMessages.get(StorageManager.UNSUCCESSFUL_CHANNEL_CHOICE_MESSAGE)));
        }
        else {
            logger.debug("doChooseChannel: current state is " + state.stateIdentifier);
            // ignore this request
        }
    }

    /**
     * a method to execute a Channel Determined Command
     * (this actually sets the channel now after the voter has answered the channel choice questions)
     * It then determines the race questions for the voter, and notifies the UI to present them to the voter
     * @param command details of the voter's answers on the channel choice questions
     */
    private void doSetChannelAndAskQuestions (ChannelDeterminedCommand command) {
        if (state.stateIdentifier == VBState.CHOOSE_CHANNEL) {
            logger.debug("doing set channel and ask questions");
            state.stateIdentifier = VBState.ANSWER_QUESTIONS;
            List<BallotAnswer> channelChoiceAnswers = command.channelChoiceAnswers;
            state.channelIdentifier = questionSelector.getChannelIdentifier(channelChoiceAnswers);
            state.channelSpecificQuestions = questionSelector.selectQuestionsForVoter(state.channelIdentifier);
            ui.askVoterQuestions(state.channelSpecificQuestions,
                    new VotingCallback(generateRequestIdentifier(),
                            state.currentBallotSerialNumber,
                            this.queue,
                            systemMessages.get(StorageManager.UNSUCCESSFUL_VOTING_MESSAGE)));
        }
        else {
            logger.debug("doSetChannelAndAskQuestions: current state is " + state.stateIdentifier);
            // ignore this request
        }
    }

    /**
     * a method to execute a Do-Finalzie-Option Command
     * notifies the UI to present the cast-or-audit question to the voter
     */
    private void doChooseFinalizeOption() {
        if (state.stateIdentifier == VBState.COMMITTING_TO_BALLOT) {
            logger.debug("doChooseFinalizeOption");
            state.stateIdentifier = VBState.CAST_OR_AUDIT;
            ui.castOrAudit(new CastOrAuditCallback(generateRequestIdentifier(),
                    state.currentBallotSerialNumber,
                    this.queue,
                    systemMessages.get(StorageManager.UNRECOGNIZED_FINALIZE_RESPONSE_MESSAGE)));
        }
        else {
            logger.debug("doChooseFinalizeOption: current state is " + state.stateIdentifier);
            // ignore this request
        }
    }

    /**
     * a method to execute a Encrypt-and-Commit Command
     * It sends a notification to commit to the output device
     * @param command details of the voter's answers on the ballot questions
     */
    private void doCommit (EncryptAndCommitBallotCommand command) {
        if (state.stateIdentifier == VBState.ANSWER_QUESTIONS) {
            logger.debug("doing commit");
            try {
                setBallotData(command);
                ui.notifyVoterToWaitForFinish(systemMessages.get(StorageManager.WAIT_FOR_COMMIT_MESSAGE),
                        new WaitForFinishCallback(generateRequestIdentifier(),
                                state.currentBallotSerialNumber,
                                this.queue,
                                systemMessages.get(StorageManager.SOMETHING_WRONG_MESSAGE)));
                outputDevice.commitToBallot(state.plaintextBallot,
                        state.signedEncryptedBallot,
                        new OutputDeviceCommitCallback(generateRequestIdentifier(),
                                state.currentBallotSerialNumber,
                                this.queue,
                                systemMessages.get(StorageManager.OUTPUT_DEVICE_FAILURE_MESSAGE)));
                state.stateIdentifier = VBState.COMMITTING_TO_BALLOT;
            }
            catch (SignatureException | IOException e) {
                logger.error("doCommit: encryption failed. exception: " + e);

                // in case the encryption failed for some unknown reason, we send the UI a notification
                // to ask the voter whether he wants to retry or cancel the ballot
                UIElement errorMessage = systemMessages.get(StorageManager.ENCRYPTION_FAILED_MESSAGE);
                UIElement[] buttons = new UIElement[]{
                        systemMessages.get(StorageManager.RETRY_BUTTON),
                        systemMessages.get(StorageManager.CANCEL_VOTE_BUTTON)};

                EncryptionFailedCallback callback = new EncryptionFailedCallback(generateRequestIdentifier(),
                        state.currentBallotSerialNumber,
                        this.queue);
                ui.showErrorMessageWithButtons(errorMessage, buttons, callback);
            }
        }
        else {
            logger.debug("doCommit: current state is " + state.stateIdentifier);
            // ignore this request
        }
    }

    /**
     * encrypt the ballot, and keep all info (plaintext, encryption and secrets) in the state's attributes
     * @param command either an EncryptAndCommitBallotCommand if we encrypt the plaintext for the first time, or a RetryEncryptAndCommitBallotCommand if we already got here but encryption failed before
     * @throws IOException problems in the encryption process
     * @throws SignatureException problems in the digital signature process
     */
    private void setBallotData (EncryptAndCommitBallotCommand command) throws IOException, SignatureException{
        // a Retry command is given only if we first got here, and later the encryption failed but the voter chose to retry
        // in such a case the plaintext is already set from previous attempt
        if (! (command instanceof RetryEncryptAndCommitBallotCommand)) {
            // this is not a retry attempt, so the plaintext is not set yet
            // otherwise, we have the plaintext from the previous encryption attempt
            state.plaintextBallot = PlaintextBallot.newBuilder()
                    .setSerialNumber(command.getBallotSerialNumber())
                    .addAllAnswers(command.getVotingAnswers())
                    .build();
        }

        // keep the encryption and the secrets we used for it
        EncryptionAndSecrets encryptionAndSecrets = crypto.encrypt(state.plaintextBallot);
        state.signedEncryptedBallot = encryptionAndSecrets.getSignedEncryptedBallot();
        state.secrets = encryptionAndSecrets.getSecrets();
    }

    /**
     * a method to execute a Cast Command or an Audit Command
     * according to the flag, chooses which finalize ballot task to send to the output device
     * @param auditRequested true if we wish to finalize by auditing. false if we finalize by casting the ballot
     */
    private void doFinalize (boolean auditRequested) {
        if (state.stateIdentifier == VBState.CAST_OR_AUDIT) {
            logger.debug("finalizing");
            state.stateIdentifier = VBState.FINALIZING;
            if (auditRequested) {
                // finalize by auditing
                ui.notifyVoterToWaitForFinish(systemMessages.get(StorageManager.WAIT_FOR_AUDIT_MESSAGE),
                        new WaitForFinishCallback(generateRequestIdentifier(),
                                state.currentBallotSerialNumber,
                                this.queue,
                                systemMessages.get(StorageManager.SOMETHING_WRONG_MESSAGE)));
                outputDevice.audit(state.secrets,
                        new OutputDeviceFinalizeCallback(generateRequestIdentifier(),
                                state.currentBallotSerialNumber,
                                this.queue,
                                systemMessages.get(StorageManager.OUTPUT_DEVICE_FAILURE_MESSAGE)));
            }
            else {
                // finalize by casting the ballot
                ui.notifyVoterToWaitForFinish(systemMessages.get(StorageManager.WAIT_FOR_CAST_MESSAGE),
                        new WaitForFinishCallback(generateRequestIdentifier(),
                                state.currentBallotSerialNumber,
                                this.queue,
                                systemMessages.get(StorageManager.SOMETHING_WRONG_MESSAGE)));
                outputDevice.castBallot(
                        new OutputDeviceFinalizeCallback(generateRequestIdentifier(),
                                state.currentBallotSerialNumber,
                                this.queue,
                                systemMessages.get(StorageManager.OUTPUT_DEVICE_FAILURE_MESSAGE)));
            }
        }
        else {
            logger.debug("doFinalize: current state is " + state.stateIdentifier);
            // ignore this request
        }
    }



    // an enum to keep the step (of the voting process) in which the VB is currently in
    private enum VBState {
        NEW_VOTER,
        CHOOSE_CHANNEL,
        ANSWER_QUESTIONS,
        COMMITTING_TO_BALLOT,
        CAST_OR_AUDIT,
        FINALIZING,
        FATAL_ERROR_FORCE_NEW_VOTER,
        SHUT_DOWN
    }


    /**
     * a class to keep and directly access all the details of the VB controller state.
     * naming:
     *  - the (enum) state identifier of the current step
     *  - the chosen channel
     *  - all details of the ballot (both plaintext and encryption)
     *  - last request identifier (to one of the other component interfaces)
     *  - serial number of the current ballot
     */
    private class ControllerState {
        public VBState stateIdentifier;
        public byte[] channelIdentifier;
        public List<BallotQuestion> channelSpecificQuestions;
        public PlaintextBallot plaintextBallot;
        public SignedEncryptedBallot signedEncryptedBallot;
        public BallotSecrets secrets;
        public int lastRequestIdentifier;
        public long currentBallotSerialNumber;

        public ControllerState () {
            plaintextBallot = null;
            signedEncryptedBallot = null;
            secrets = null;
            lastRequestIdentifier = -1;
            channelIdentifier = null;
            channelSpecificQuestions = null;
            currentBallotSerialNumber = 0;
        }

        private void clearPlaintext () {
            plaintextBallot = null;
        }

        private void clearCiphertext () {
            signedEncryptedBallot = null;
            secrets = null;
        }

        public void clearAndResetState(VBState newStateIdentifier) {
            state.clearPlaintext();
            state.clearCiphertext();
            state.stateIdentifier = newStateIdentifier;
            state.currentBallotSerialNumber += 1;
        }

    }


    /**
     * Creates a new request identifier to identify any call to one of the other component interfaces.
     * We limit its value to MAX_REQUEST_IDENTIFIER, so the identifier is kept short.
     * @return a new request identifier
     */
    private int generateRequestIdentifier() {
        ++requestCounter;
        if (requestCounter >= MAX_REQUEST_IDENTIFIER) {
            requestCounter = 1;
        }
        return requestCounter;
    }


}

