/*
 * 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.products.ov.validation.services;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.PrivateKey;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.stream.Collectors;

import javax.json.Json;
import javax.json.JsonWriter;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.output.FileWriterWithEncoding;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import com.googlecode.jcsv.writer.CSVWriter;
import com.googlecode.jcsv.writer.internal.CSVWriterBuilder;
import com.scytl.cryptolib.api.exceptions.GeneralCryptoLibException;
import com.scytl.cryptolib.api.primitives.PrimitivesServiceAPI;
import com.scytl.cryptolib.elgamal.cryptoapi.Ciphertext;
import com.scytl.cryptolib.primitives.primes.utils.PemUtils;
import com.scytl.products.oscore.logging.api.domain.Level;
import com.scytl.products.oscore.logging.api.domain.LogContent;
import com.scytl.products.oscore.logging.api.domain.LogContent.LogContentBuilder;
import com.scytl.products.oscore.logging.api.writer.SecureLoggingWriter;
import com.scytl.products.oscore.logging.core.provider.TransactionInfoProvider;
import com.scytl.products.ov.commons.beans.Ballot;
import com.scytl.products.ov.commons.beans.Contest;
import com.scytl.products.ov.commons.beans.ElectionOption;
import com.scytl.products.ov.commons.beans.Question;
import com.scytl.products.ov.commons.sign.MetadataFileSigner;
import com.scytl.products.ov.commons.sign.SignatureMetadata;
import com.scytl.products.ov.commons.sign.beans.SignatureFieldsConstants;
import com.scytl.products.ov.log.ValidationLogConstants;
import com.scytl.products.ov.log.ValidationLogEvents;
import com.scytl.products.ov.mixnet.commons.beans.VoteWithProof;
import com.scytl.products.ov.validation.ValidationEngineException;
import com.scytl.products.ov.validation.ValidationException;
import com.scytl.products.ov.validation.ValidatorConfig;
import com.scytl.products.ov.validation.commons.beans.AuditableVote;
import com.scytl.products.ov.validation.commons.beans.BallotDecryptionData;
import com.scytl.products.ov.validation.commons.beans.DecompressedVote;
import com.scytl.products.ov.validation.commons.beans.Factorization;
import com.scytl.products.ov.validation.commons.beans.ValidationFailure;
import com.scytl.products.ov.validation.commons.beans.ValidationFailureCodes;
import com.scytl.products.ov.validation.commons.writeins.WriteinEncoder;
import com.scytl.products.ov.validation.decompress.GroupElementDecompressor;
import com.scytl.products.ov.validation.decompress.VoteValidator;
import com.scytl.products.ov.validation.services.input.AuditableVoteConverter;
import com.scytl.products.ov.validation.services.input.DecompressedVoteConverter;
import com.scytl.products.ov.validation.services.input.VotesWithProofReader;
import com.scytl.products.ov.validation.utils.ElGamalHelper;

/**
 * Standard implementation of the validation service.
 */
public class ValidationServiceImpl implements ValidationService {

    private static final Logger LOG = LoggerFactory.getLogger(ValidationServiceImpl.class);

    private static final String VOTES_WITH_PROOF_FILE_NAME = "votesWithProof.csv";

    private static final String AUDITABLE_VOTES_FILE_NAME = "auditableVotes.csv";

    private static final String DECOMPRESSED_VOTES_FILE_NAME = "decompressedVotes.csv";

    private static final String MIXING_SERVICE_NAME = "mixing";

    private final VotesWithProofReader votesWithProofReader;

    private final GroupElementDecompressor groupElementDecompressor;

    private final VoteValidator voteValidator;

    private final PrimitivesServiceAPI primitivesService;

    private final SecureLoggingWriter secureLogger;

    private final TransactionInfoProvider transactionInfoProvider;

    private final MetadataFileSigner metadataFileSigner;

    @Autowired
    public ValidationServiceImpl(VotesWithProofReader votesWithProofReader,
            GroupElementDecompressor groupElementDecompressor, VoteValidator voteValidator,
            PrimitivesServiceAPI primitivesService, SecureLoggingWriter secureLogger,
            TransactionInfoProvider transactionInfoProvider, MetadataFileSigner metadataFileSigner) {
        super();
        this.votesWithProofReader = votesWithProofReader;
        this.groupElementDecompressor = groupElementDecompressor;
        this.primitivesService = primitivesService;
        this.secureLogger = secureLogger;
        this.transactionInfoProvider = transactionInfoProvider;
        this.voteValidator = voteValidator;
        this.metadataFileSigner = metadataFileSigner;
    }

    @Override
    public void validate(ValidatorConfig config, List<Path> mixingPaths) throws ValidationException {
        WriteinEncoder writeinEncoder = new WriteinEncoder(config.getWriteInAlphabet());
        Ballot ballot = config.getBallot();
        String ballotBoxId = config.getBallotBoxId();
        String electionEventId = config.getElectionEventId();
        String tenantId = config.getTenantId();
        BigInteger encryptionParamsP = new BigInteger(config.getElGamalEncryptionParametersP());
        List<String> validWriteInRepresentations = getValidWriteInRepresentations(ballot.getContests());
        PrivateKey signingKey = getSigningKeyFromPEM(config.getPrivateKeyPEM());

        transactionInfoProvider.generate(tenantId, "", "");

        Path outputPath = config.getOutputPath();
        Path decompressedVotesFilePath = getDecompressedVotesFilePath(outputPath);
        Path auditableVotesFilePath = getAuditableVotesFilePath(outputPath);
        try (
                // Open output file to write valid votes to.
                CSVWriter<DecompressedVote> validVoteWriter =
                    createDecompressedVoteWriter(decompressedVotesFilePath.toFile());
                // Open output file to write invalid votes to.
                CSVWriter<AuditableVote> invalidVoteWriter =
                    createAuditableVoteWriter(auditableVotesFilePath.toFile())) {
            for (Path mixingPath : mixingPaths) {
                // Load the votes with proof, validate each of them, and commit
                // the results.
                votesWithProofReader.read(getVotesWithProofFilePath(mixingPath).toFile(), electionEventId, ballotBoxId)
                    .forEach(voteWithProof -> processItem(voteWithProof, validVoteWriter, invalidVoteWriter, ballot,
                        encryptionParamsP, writeinEncoder, validWriteInRepresentations));
            }
        } catch (IOException e) {
            throw new ValidationException("Failed validating the decrypted votes", e);
        }

        signValidationOutputs(auditableVotesFilePath, decompressedVotesFilePath, signingKey, tenantId, electionEventId,
            ballotBoxId);
    }

    private PrivateKey getSigningKeyFromPEM(String signingKeyPEM) throws ValidationException {
        try {
            return PemUtils.privateKeyFromPem(signingKeyPEM);
        } catch (GeneralCryptoLibException e) {
            throw new ValidationException("Could not recover signing private key from PEM", e);
        }
    }

    private LinkedHashMap<String, String> getMetadataFields(String tenantId, String electionEventId,
            String ballotBoxId) {

        final LinkedHashMap<String, String> fields = new LinkedHashMap<>();
        fields.put(SignatureFieldsConstants.SIG_FIELD_TENANTID, tenantId);
        fields.put(SignatureFieldsConstants.SIG_FIELD_EEVENTID, electionEventId);
        fields.put(SignatureFieldsConstants.SIG_FIELD_BBOXID, ballotBoxId);
        fields.put(SignatureFieldsConstants.SIG_FIELD_TIMESTAMP, DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
        fields.put(SignatureFieldsConstants.SIG_FIELD_COMPONENT, MIXING_SERVICE_NAME);

        return fields;
    }

    private void signValidationOutputs(Path auditableVotesPath, Path decompressedVotesPath, PrivateKey signingKey,
            String tenantId, String electionEventId, String ballotBoxId)
            throws ValidationException {

        LinkedHashMap<String, String> signatureFields = getMetadataFields(tenantId, electionEventId, ballotBoxId);

        Path[] validationOutputPaths = {auditableVotesPath, decompressedVotesPath };

        for (Path originalFilePath : validationOutputPaths) {
            try {

                final Path metadataFilePath =
                    originalFilePath.getParent().resolve(originalFilePath.getFileName() + ".metadata");

                try (InputStream csvFileIn = Files.newInputStream(originalFilePath);
                        OutputStream os = Files.newOutputStream(metadataFilePath, StandardOpenOption.CREATE);
                        JsonWriter jsonWriter = Json.createWriter(os)) {
                    final SignatureMetadata signature =
                        metadataFileSigner.createSignature(signingKey, csvFileIn, signatureFields);
                    jsonWriter.writeObject(signature.toJsonObject());
                }
            } catch (IOException | GeneralCryptoLibException e) {
                throw new ValidationException("Error signing the ballot box validation outputs", e);
            }
        }

    }

    /**
     * @return the votes with proofs' file in the selected path.
     */
    public static Path getVotesWithProofFilePath(Path inputDirectory) {
        return inputDirectory.resolve(VOTES_WITH_PROOF_FILE_NAME);
    }

    /**
     * @return the votes with proofs' file in the selected path.
     */
    public static Path getAuditableVotesFilePath(Path outputDirectory) {
        return outputDirectory.resolve(AUDITABLE_VOTES_FILE_NAME);
    }

    /**
     * @return the votes with proofs' file in the selected path.
     */
    public static Path getDecompressedVotesFilePath(Path outputDirectory) {
        return outputDirectory.resolve(DECOMPRESSED_VOTES_FILE_NAME);
    }

    /**
     * Processes a vote with proof.
     *
     * @param voteWithProof
     *            the vote with proof to process
     * @param auditableVoteWriter
     *            the CSV writer to write the results to
     */
    private void processItem(VoteWithProof voteWithProof, CSVWriter<DecompressedVote> decompressedVoteWriter,
            CSVWriter<AuditableVote> auditableVoteWriter, Ballot ballot, BigInteger encryptionParamsP,
            WriteinEncoder writeInEncoder, List<String> validWriteInRepresentations) {
        // Validate the decrypted vote.
        DecryptedVoteOutput output = validateVoteWithProof(voteWithProof, encryptionParamsP, writeInEncoder, ballot,
            validWriteInRepresentations);
        try {
            // If the vote could be decompressed, store that form; otherwise
            // store it as auditable.
            if (output.getDecompressedVote() != null) {
                decompressedVoteWriter.write(output.getDecompressedVote());
            } else {
                auditableVoteWriter.write(output.getAuditableVote());
            }
        } catch (IOException e) {
            LOG.warn(ValidationLogEvents.VOTES_WITH_PROOF_READING_FAILURE.getInfo(), e);
            reportValidationFailure(voteWithProof, ballot.getElectionEvent().getId(), e.getMessage(),
                ValidationLogEvents.VOTES_WITH_PROOF_READING_FAILURE);
        }
    }

    /**
     * Reports a failure while processing a vote with proof.
     */
    private void reportValidationFailure(VoteWithProof voteWithProof, String electionEventId, String failureReasons,
            ValidationLogEvents logEvent) {
        String hash = generateHash(voteWithProof.getEncrypted());
        secureLogger.log(Level.WARN,
            new LogContent.LogContentBuilder().logEvent(logEvent).user("adminID").electionEvent(electionEventId)
                .additionalInfo(ValidationLogConstants.INFO_HASH_VOTE, hash)
                .additionalInfo(ValidationLogConstants.INFO_ERR_DESC, failureReasons).createLogInfo());
    }

    /**
     * Validates a decrypted vote.
     *
     * @param voteWithProof
     *            the decrypted vote to validate
     * @return object with the "decrypted vote"+proof or "auditable vote"+
     *         "validation failure"
     */
    public DecryptedVoteOutput validateVoteWithProof(final VoteWithProof voteWithProof, BigInteger encryptionParamsP,
            WriteinEncoder writeinEncoder, Ballot ballot, List<String> validWriteInRepresentations) {

        this.transactionInfoProvider.generate("", "", "");

        DecompressedVote decompressedVote = null;
        AuditableVote auditableVote = null;

        LOG.debug("Processing encrypted ballot...");

        // decryptWithAProofAndObtainFactorization
        final List<ValidationFailure> validationFailures = new ArrayList<>();

        List<BigInteger> decrypted = voteWithProof.getDecrypted();
        Factorization factorization = groupElementDecompressor.decompress(decrypted.get(0), ballot);
        final BallotDecryptionData ballotDecryptionData = new BallotDecryptionData(factorization,
            decrypted.size() > 1 ? decrypted.subList(1, decrypted.size()) : Collections.emptyList());
        // --decryptWithAProofAndObtainFactorization
        List<String> decodedWriteIns = new ArrayList<>();
        try {
            List<String> usableWriteInRepresentations =
                filterUsableWriteInRepresentations(factorization, validWriteInRepresentations);
            decodedWriteIns = writeinEncoder.decode(ballotDecryptionData.getWriteIns(), encryptionParamsP,
                usableWriteInRepresentations);
        } catch (IllegalArgumentException ex) {
            LOG.warn("Error trying to validate Vote with proof.", ex);
            validationFailures
                .add(new ValidationFailure(ex.getMessage(), ValidationFailureCodes.WRITE_IN_CONTENT_VIOLATION));
        }

        boolean writeInValidationPasses = validationFailures.isEmpty();
        if (writeInValidationPasses && voteValidator.validate(ballot, factorization, validationFailures)) {
            decompressedVote = new DecompressedVote(factorization.getFactors(), decodedWriteIns);
        } else {
            auditableVote = new AuditableVote(factorization, validationFailures);

            String failureReasons =
                validationFailures.stream().map(ValidationFailure::toString).collect(Collectors.joining(","));

            reportValidationFailure(voteWithProof, failureReasons, ballot.getElectionEvent().getId(),
                ValidationLogEvents.RECEBPK_ERROR_VALIDATING_FACTORIZED_VOTE);

            LOG.debug("The element could not be decompressed to a valid result: {}", validationFailures);
        }

        // SV-6481 check if we need to log every ballot. could we move this to the
        // listener?
        secureLogger.log(Level.INFO,
            new LogContentBuilder().logEvent(ValidationLogEvents.RECEBPK_SUCCESS_BATCH_OF_VOTES_DECRYPTED)
                .user("adminID").electionEvent(ballot.getElectionEvent().getId()).createLogInfo());

        LOG.debug("Encrypted ballot {} processed successfully", ballot.getId());

        return DecryptedVoteOutput.create(auditableVote, voteWithProof, decompressedVote);
    }

    /**
     * Gets a list of valid write-in representations from a list of contests.
     *
     * @param contests
     *            the contests to extract the write-in representations from
     * @return the write-in representations
     */
    private List<String> getValidWriteInRepresentations(List<Contest> contests) {
        // Build a list of write-in attributes.
        List<String> writeInAttributes = contests.stream().map(Contest::getQuestions)
            .flatMap(questions -> questions.stream()).map(Question::getWriteInAttribute).collect(Collectors.toList());

        // Get all representations of the options whose write-in attribute is in
        // the previously compiled list.
        return contests.stream().map(Contest::getOptions).flatMap(options -> options.stream())
            .filter(option -> writeInAttributes.contains(option.getAttribute())).map(ElectionOption::getRepresentation)
            .collect(Collectors.toList());
    }

    /**
     * Compiles a list of usable write-in representations.
     *
     * @return the usable representations
     */
    private List<String> filterUsableWriteInRepresentations(Factorization factorization,
            List<String> validWriteInRepresentations) {
        return factorization.getFactors().stream().map(factor -> factor.getBase().toString())
            .filter(base -> validWriteInRepresentations.contains(base)).collect(Collectors.toList());
    }

    /**
     * Generates a hash of the encrypted ballot for the secure log.
     *
     * @param encryptedBallot
     *            the source of the hash
     * @return a hash of the encrypted ballot
     */
    private String generateHash(Ciphertext encryptedBallot) {
        try {
            return Base64.encodeBase64String(
                primitivesService.getHash(ElGamalHelper.concatValues(encryptedBallot.getElements())));
        } catch (GeneralCryptoLibException e) {
            throw new ValidationEngineException("Failed generating the vote hash: " + encryptedBallot.toString(), e);
        }
    }

    private CSVWriter<AuditableVote> createAuditableVoteWriter(File file) throws IOException {
        return new CSVWriterBuilder<AuditableVote>(getNewWriter(file)).entryConverter(new AuditableVoteConverter())
            .build();
    }

    private CSVWriter<DecompressedVote> createDecompressedVoteWriter(File file) throws IOException {
        return new CSVWriterBuilder<DecompressedVote>(getNewWriter(file))
            .entryConverter(new DecompressedVoteConverter()).build();
    }

    private static Writer getNewWriter(File file) throws IOException {
        return new FileWriterWithEncoding(file, StandardCharsets.UTF_8);
    }
}
