/*
 * Copyright 2018 Scytl Secure Electronic Voting SA
 *
 * All rights reserved
 *
 * See our extended copyright notice in *file 'Copyright.txt' which is part of this source code package
 */
package com.scytl.ov.mixing.core.elgamal;

import static java.util.Objects.requireNonNull;

import java.security.SecureRandom;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeoutException;

import com.scytl.cryptolib.api.exceptions.GeneralCryptoLibException;
import com.scytl.cryptolib.elgamal.bean.CiphertextImpl;
import com.scytl.cryptolib.elgamal.bean.ElGamalPublicKey;
import com.scytl.cryptolib.elgamal.cryptoapi.Ciphertext;
import com.scytl.cryptolib.mathematical.groups.impl.Exponent;
import com.scytl.ov.mixing.core.InvalidJobParametersException;
import com.scytl.ov.mixing.core.InvalidMixablesException;
import com.scytl.ov.mixing.core.JobEvent;
import com.scytl.ov.mixing.core.JobEventListener;
import com.scytl.ov.mixing.core.JobParameters;
import com.scytl.ov.mixing.core.JobState;
import com.scytl.ov.mixing.core.MixJob;
import com.scytl.ov.mixing.core.Mixable;
import com.scytl.ov.mixing.core.MixingException;
import com.scytl.ov.mixing.core.MixingProcess;
import com.scytl.ov.mixing.core.shuffle.PermutationGenerator;
import com.scytl.ov.mixing.core.shuffle.Permutator;
import com.scytl.ov.mixing.shuffle.Permutation;

/**
 * A job that allows lists of mixables to be mixed.
 * <P>
 * This class can mix lists of objects that implement {@link ElGamalMixable}.
 */
public class ElGamalMixJob implements MixJob {

    private static final int ZERO_PERCENT = 0;

    private static final int PROGRESS_WHEN_CANCELED_OR_FAILED = ZERO_PERCENT;

    protected static final int ONE_HUNDRED_PERCENT = 100;

    protected JobState jobState = JobState.CREATED;

    protected int progress;

    protected List<ElGamalMixable> encryptedMixables;

    protected List<Ciphertext> encryptedMixablesCiphertexts;

    protected List<ElGamalMixable> mixedMixables;

    protected ElGamalJobParameters elGamalJobParameters;

    protected InvalidMixablesException invalidMixablesException;

    protected MixingException mixingException;

    protected Permutation permutation;

    protected List<Exponent> exponentsUsedDuringEncryption;

    private List<JobEventListener> listeners;

    /**
     * Constructor.
     */
    public ElGamalMixJob() {

        listeners = Collections.synchronizedList(new ArrayList<>());

        updateStateAndProgress(JobState.CREATED, ZERO_PERCENT);

        encryptedMixables = new ArrayList<>();
        encryptedMixablesCiphertexts = new ArrayList<>();
        mixedMixables = new ArrayList<>();
        exponentsUsedDuringEncryption = new ArrayList<>();

        invalidMixablesException = null;
        mixingException = null;
    }

    /**
     * It is important to realize that the mixing process will update the objects in the received list, and add those
     * same objects to the list that is returned when getMix is called. If the original list is needed again by the
     * caller (for example, if the caller wishes to verify the proofs) then the caller should make a copy of the
     * original list.
     */
    @Override
    public void start(List<Mixable> mixables) {

        verifyStateCorrectToStartJob();

        performBasicValidationsOnStartInput(mixables);

        try {

            for (Mixable mixable : mixables) {

                if (mixable instanceof ElGamalMixable) {

                    ElGamalMixable elGamalMixable = (ElGamalMixable) mixable;

                    this.encryptedMixables.add(elGamalMixable);

                    Ciphertext ciphertext = new CiphertextImpl(elGamalMixable.getCiphertext().getGamma(),
                        elGamalMixable.getCiphertext().getPhis());

                    this.encryptedMixablesCiphertexts.add(ciphertext);

                } else {
                    throw new InvalidMixablesException(
                        "The list of mixables contained something that is not an ElGamalMixable");
                }
            }

            updateStateAndProgress(JobState.STARTED, ZERO_PERCENT);

            updateProgress(MixingProcess.SHUFFLE, 50);
            updateProgress(MixingProcess.SHUFFLE, 60);

            shuffle();

            updateProgress(MixingProcess.SHUFFLE, ONE_HUNDRED_PERCENT);

            updateProgress(MixingProcess.REENCRYPTION, 50);
            updateProgress(MixingProcess.REENCRYPTION, 60);

            reencrypt();

            updateProgress(MixingProcess.REENCRYPTION, ONE_HUNDRED_PERCENT);

        } catch (InvalidMixablesException exception) {

            setStateOfFailedJob();
            this.invalidMixablesException = exception;

        } catch (MixingException exception) {

            setStateOfFailedJob();
            this.mixingException = exception;
        } catch (GeneralCryptoLibException exception) {

            setStateOfFailedJob();
            this.mixingException = new MixingException("Exception during mixing", exception);
        }
    }

    @Override
    public void addEventListener(JobEventListener listener) {
        listeners.add(listener);
        updateListeners();
    }

    @Override
    public void await() throws InterruptedException {

    }

    @Override
    public void await(Duration timeout) throws InterruptedException, TimeoutException {

    }

    @Override
    public void cancel() {
        updateStateAndProgress(JobState.CANCELED, PROGRESS_WHEN_CANCELED_OR_FAILED);
    }

    @Override
    public int progress() {
        return progress;
    }

    @Override
    public void removeEventListener(JobEventListener listener) {
        listeners.add(listener);
    }

    @Override
    public void setParameters(JobParameters parameters) throws InvalidJobParametersException {

        if (parameters instanceof ElGamalJobParameters) {
            elGamalJobParameters = (ElGamalJobParameters) parameters;
        } else {
            throw new IllegalArgumentException("Recieved job parameters which are not for ElGamal");
        }
    }

    @Override
    public JobState state() {
        return jobState;
    }

    @Override
    public <T extends Mixable> List<T> getMix(Class<T> cls) throws InvalidMixablesException, MixingException {

        validateStateCorrectAndThatThereWereNoExceptionsDuringMixing();

        requireNonNull(cls, "The received class was null");

        List<T> list = new ArrayList<>();
        for (ElGamalMixable mixable : this.mixedMixables) {

            if (cls.isInstance(mixable)) {
                list.add(cls.cast(mixable));
            } else {
                throw new InvalidMixablesException(
                    "An element in the mixed list does not match the specified class type");
            }
        }

        return list;
    }

    private static void performBasicValidationsOnStartInput(List<Mixable> mixables) {

        requireNonNull(mixables, "The received list of mixables was null");
        if (mixables.isEmpty()) {
            throw new IllegalArgumentException("The received list of mixables was empty");
        }
    }

    private void verifyStateCorrectToStartJob() {

        if (elGamalJobParameters == null) {
            throw new IllegalStateException("Started the job without first setting the job parameters");
        }

        if (this.jobState == JobState.STARTED) {
            throw new IllegalStateException("Tried to start a job that was already started");
        } else if (this.jobState == JobState.CANCELED) {
            throw new IllegalStateException("Tried to start a job that was canceled");
        } else if (this.jobState == JobState.FINISHED) {
            throw new IllegalStateException("Tried to start a job that was finished");
        }
    }

    protected void validateStateCorrectAndThatThereWereNoExceptionsDuringMixing() throws MixingException {

        if (this.invalidMixablesException != null) {
            throw this.invalidMixablesException;
        } else if (this.mixingException != null) {
            throw this.mixingException;
        }

        if (this.jobState == JobState.CREATED) {
            throw new IllegalStateException("Tried to retrieve the mixed list before the job was started");
        } else if (this.jobState == JobState.CANCELED) {
            throw new IllegalStateException("Tried to retrieve the mixed list from a job that was canceled");
        } else if (this.jobState != JobState.FINISHED) {
            throw new IllegalStateException("Tried to retrieve the mixed list from a job that had not finished");
        }
    }

    protected void setStateOfFailedJob() {
        updateStateAndProgress(JobState.FINISHED, PROGRESS_WHEN_CANCELED_OR_FAILED);
    }

    // Temporary crude approach that assumes that each of the 2 steps (shuffle, reencryption) takes the
    // same amount of time
    protected void updateProgress(MixingProcess mixingProcess, int progress) {

        final int factor = 2;

        if (mixingProcess == MixingProcess.SHUFFLE) {
            updateProgress(progress / factor);
        } else if (mixingProcess == MixingProcess.REENCRYPTION) {

            if (progress == ONE_HUNDRED_PERCENT) {
                updateStateAndProgress(JobState.FINISHED, ONE_HUNDRED_PERCENT);
            } else {
                updateProgress((ONE_HUNDRED_PERCENT / factor) + (progress / factor));
            }
        }

        updateListeners();
    }

    protected void updateProgress(int progress) {

        this.progress = progress;
        updateListeners();
    }

    protected void updateStateAndProgress(JobState jobState, int progress) {

        this.jobState = jobState;
        this.progress = progress;
        updateListeners();
    }

    private void shuffle() {

        PermutationGenerator permutationGenerator = new PermutationGenerator(new SecureRandom());

        this.permutation = permutationGenerator.generate(encryptedMixables.size());
        Permutator<ElGamalMixable> permutator = new Permutator<>();
        this.mixedMixables = permutator.permutate(this.encryptedMixables, permutation);
    }

    private void reencrypt() throws MixingException {

        ElGamalPublicKey elGamalPublicKey = elGamalJobParameters.publicKey();

        ElGamalReencrypter reencrypter;
        try {
            reencrypter = new ElGamalReencrypter(elGamalPublicKey);
        } catch (GeneralCryptoLibException e) {
            throw new MixingException("Exception while creating an encrypter", e);
        }

        try {

            for (ElGamalMixable mixable : this.mixedMixables) {

                ReencryptionOutput output = reencrypter.reencrypt(mixable.getCiphertext());
                mixable.setCiphertext(output.getCiphertext());
                exponentsUsedDuringEncryption.add(output.getExponent());
            }

        } catch (GeneralCryptoLibException e) {
            throw new MixingException("Exception while performing reencryption", e);
        }
    }

    protected void updateListeners() {

        synchronized (listeners) {
            for (JobEventListener jobEventListener : listeners) {
                JobEvent jobEvent = new JobEvent(this);
                jobEventListener.stateChanged(jobEvent);
            }
            for (JobEventListener jobEventListener : listeners) {
                JobEvent jobEvent = new JobEvent(this);
                jobEventListener.progressChanged(jobEvent);
            }
        }
    }
}
