/*
 * 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.ea.services.infrastructure.persistence;

import static java.nio.file.Files.newBufferedReader;

import java.io.IOException;
import java.io.OutputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import javax.ejb.EJB;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.inject.Inject;
import javax.persistence.LockTimeoutException;
import javax.persistence.PessimisticLockException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.databind.MappingIterator;
import com.scytl.cryptolib.api.exceptions.GeneralCryptoLibException;
import com.scytl.cryptolib.derivation.cryptoapi.CryptoAPIDerivedKey;
import com.scytl.cryptolib.derivation.cryptoapi.CryptoAPIPBKDFDeriver;
import com.scytl.cryptolib.primitives.primes.utils.PemUtils;
import com.scytl.cryptolib.primitives.service.PrimitivesService;
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.writer.SecureLoggingWriter;
import com.scytl.products.ov.commons.beans.domain.model.Information.VoterInformation;
import com.scytl.products.ov.commons.beans.domain.model.certificate.CertificateEntity;
import com.scytl.products.ov.commons.beans.exceptions.ApplicationException;
import com.scytl.products.ov.commons.beans.exceptions.DuplicateEntryException;
import com.scytl.products.ov.commons.beans.exceptions.EntryPersistenceException;
import com.scytl.products.ov.commons.beans.exceptions.ResourceNotFoundException;
import com.scytl.products.ov.commons.infrastructure.remote.client.RetrofitException;
import com.scytl.products.ov.commons.infrastructure.remote.service.RemoteCertificateService;
import com.scytl.products.ov.commons.infrastructure.transaction.TransactionController;
import com.scytl.products.ov.commons.infrastructure.transaction.TransactionalAction;
import com.scytl.products.ov.commons.infrastructure.transaction.TransactionalActionException;
import com.scytl.products.ov.commons.tracking.TrackIdInstance;
import com.scytl.products.ov.commons.ui.ws.rs.ObjectMappers;
import com.scytl.products.ov.commons.verify.CSVVerifier;
import com.scytl.products.ov.ea.domain.model.EncryptedSVK;
import com.scytl.products.ov.ea.domain.model.extendedauthentication.AllowedAttemptsExceededException;
import com.scytl.products.ov.ea.domain.model.extendedauthentication.EaCsvVerifier;
import com.scytl.products.ov.ea.domain.model.extendedauthentication.ExtendedAuthentication;
import com.scytl.products.ov.ea.domain.model.extendedauthentication.ExtendedAuthenticationRepository;
import com.scytl.products.ov.ea.services.infrastructure.csv.FailedAuthenticationVotingCardItem;
import com.scytl.products.ov.ea.services.infrastructure.csv.FailedAuthenticationVotingCardsItemWriter;
import com.scytl.products.ov.ea.services.infrastructure.logging.ExtendedAuthenticationLogConstants;
import com.scytl.products.ov.ea.services.infrastructure.logging.ExtendedAuthenticationLogEvents;
import com.scytl.products.ov.ea.services.infrastructure.remote.EaRemoteCertificateService;
import com.scytl.products.ov.ea.services.infrastructure.remote.VoterMaterialService;

/**
 * The implementation of an Extended Authentication Service.
 */
@Stateless
public class ExtendedAuthenticationServiceImpl implements ExtendedAuthenticationService {

    // 4 means that the allowed max attempts is 5!
    public static final Integer MAX_ALLOWED_NUMBER_OF_ATTEMPTS = 4;

    private static final int MIN_EXTRA_PARAM_LENGTH = 16;

    private static final int BATCH_SIZE = Integer.parseInt(System.getProperty("synchronization.batch.size", "1000"));

    private static final String ADMINISTRATION_BOARD_CN_PREFIX = "AdministrationBoard ";

    @Inject
    protected ExtendedAuthenticationRepository extendedAuthenticationRepository;

    @EJB(beanName = "ExtendedAuthenticationTransactionController")
    protected TransactionController transactionController;

    protected PrimitivesService primitivesService;

    @Inject
    protected SecureLoggingWriter secureLoggingWriter;

    private static final Logger LOG = LoggerFactory.getLogger("std");

    @Inject
    protected TrackIdInstance trackIdInstance;

    @Inject
    @EaCsvVerifier
    protected CSVVerifier verifier;

    @Inject
    @EaRemoteCertificateService
    protected RemoteCertificateService remoteCertificateService;

    @Inject
    private VoterMaterialService voterMaterialService;

    public ExtendedAuthenticationServiceImpl() throws GeneralCryptoLibException {
        primitivesService = new PrimitivesService();
    }

    /**
     * {@inheritDoc}
     * 
     * @throws GeneralCryptoLibException
     */
    @Override
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public EncryptedSVK authenticate(final String tenantId, final String authId, final String extraParam,
            final String electionEventId)
            throws ResourceNotFoundException, AllowedAttemptsExceededException, AuthenticationException,
            ApplicationException, GeneralCryptoLibException {

        ExtendedAuthentication extendedAuthentication =
            retrieveExistingExtendedAuthenticationForUpdate(tenantId, authId, electionEventId);

        Integer attempts = extendedAuthentication.getAttempts();
        if (attempts == null) {
            attempts = 0;
        }

        if (MAX_ALLOWED_NUMBER_OF_ATTEMPTS.compareTo(attempts) < 0) {
            LOG.error("Exceeded allowed attempts.");
            throw new AllowedAttemptsExceededException();
        }

        if (!attemptIsValid(extraParam, extendedAuthentication.getExtraParam(), extendedAuthentication.getSalt())) {
            int increasedAttempts = attempts + 1;
            extendedAuthentication.setAttempts(increasedAttempts);

            try {
                extendedAuthenticationRepository.update(extendedAuthentication);
            } catch (EntryPersistenceException e) {
                LOG.error("Error in repository trying to update the ExtendedAuthentication object.", e);
                throw new AuthenticationException(e, attempts);
            }
            LOG.error("Invalid attempt.");
            throw new AuthenticationException("invalid extra parameter", MAX_ALLOWED_NUMBER_OF_ATTEMPTS - attempts);
        }
        return new EncryptedSVK(extendedAuthentication.getEncryptedStartVotingKey());
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public ExtendedAuthentication renewExistingExtendedAuthentication(final String tenantId, final String oldAuthId,
            final String newAuthId, final String newSVK, final String electionEventId)
            throws ApplicationException, ResourceNotFoundException {

        ExtendedAuthentication existingExtendedAuthentication =
            retrieveExistingExtendedAuthenticationForUpdate(tenantId, oldAuthId, electionEventId);

        extendedAuthenticationRepository.delete(existingExtendedAuthentication);

        ExtendedAuthentication newExtendedAuthentication =
            createNewEA(newSVK, newAuthId, existingExtendedAuthentication);

        try {
            extendedAuthenticationRepository.save(newExtendedAuthentication);
            return newExtendedAuthentication;

        } catch (DuplicateEntryException e) {
            LOG.error("Error in repository trying to save the ExtendedAuthentication object.", e);
            throw new ApplicationException(e.getMessage());
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public ExtendedAuthentication retrieveExistingExtendedAuthenticationForUpdate(final String tenantId,
            final String authId, final String electionEventId) throws ResourceNotFoundException, ApplicationException {
        Optional<ExtendedAuthentication> extendedAuthentication;
        try {
            extendedAuthentication = extendedAuthenticationRepository.getForUpdate(tenantId, authId, electionEventId);
            if (!extendedAuthentication.isPresent()) {
                String errorMsg = String.format(
                    "Not found Extended Authentication with Id=%s, Tenant Id=%s and Election Event Id= %s.", authId,
                    tenantId, electionEventId);
                LOG.error(errorMsg);
                throw new ResourceNotFoundException(errorMsg);
            }
        } catch (PessimisticLockException | LockTimeoutException e) {
            // debug because this exception should not be logged in production
            LOG.debug(e.getMessage(), e);
            String errorMsg = String.format(
                "Cannot create lock on Extended Authentication entity with Id=%s, Tenant Id=%s and Election Event Id= %s.",
                authId, tenantId, electionEventId);
            LOG.error(errorMsg);
            throw new ApplicationException(errorMsg);
        }
        return extendedAuthentication.get();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public ExtendedAuthentication retrieveExistingExtendedAuthenticationForRead(final String tenantId,
            final String authId, final String electionEventId) throws ResourceNotFoundException, ApplicationException {
        Optional<ExtendedAuthentication> extendedAuthentication;
        extendedAuthentication = extendedAuthenticationRepository.getForRead(tenantId, authId, electionEventId);
        if (!extendedAuthentication.isPresent()) {
            String errorMsg =
                String.format("Not found Extended Authentication with Id=%s, Tenant Id=%s and Election Event Id= %s.",
                    authId, tenantId, electionEventId);
            LOG.error(errorMsg);
            throw new ResourceNotFoundException(errorMsg);
        }
        return extendedAuthentication.get();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean saveExtendedAuthenticationFromFile(final Path file, final String tenantId,
            final String electionEventId, final String adminBoardId) throws IOException {
        if (verifyAndRemoveSignature(tenantId, electionEventId, adminBoardId, file)) {
            saveExtendedAuthenticationFileContents(file, tenantId, electionEventId, adminBoardId);
            return true;
        }
        return false;

    }

    private boolean attemptIsValid(final String extraParam, String storedExtraParam, String salt)
            throws GeneralCryptoLibException {
        if (salt != null && salt.length() != 0) {
            return checkIfChallengeMatches(extraParam, salt, storedExtraParam);
        }
        // In case the attempt does not contain salt, that means that we are not using challenge based authentication.
        return true;
    }

    private boolean verifyAndRemoveSignature(final String tenantId, final String electionEventId,
            final String adminBoardId, final Path file) throws IOException {
        boolean valid = false;

        try {
            PublicKey key = getAdminBoardPublicKey(adminBoardId);
            valid = verifier.verify(key, file);
            if (valid) {
                secureLoggingWriter.log(Level.INFO,
                    new LogContent.LogContentBuilder()
                        .logEvent(ExtendedAuthenticationLogEvents.EASIGVER_SIGNATURE_VERIFICATION_SUCCESS).objectId("")
                        .user(tenantId).electionEvent(electionEventId)
                        .additionalInfo(ExtendedAuthenticationLogConstants.INFO_TRACK_ID, trackIdInstance.getTrackId())
                        .additionalInfo(ExtendedAuthenticationLogConstants.INFO_ADMIN_BOARD_ID, adminBoardId)
                        .createLogInfo());
            } else {
                LOG.error("Extended Authentication data signature is not valid");
                String additionalInfo = "Failed to verify signature of received EA data";
                logInvalidSignatureOfEaData(tenantId, electionEventId, adminBoardId, additionalInfo);
            }
        } catch (GeneralCryptoLibException | RetrofitException e) {
            LOG.error("Extended Authentication data signature could not be verified", e);
            String additionalInfo =
                "Failed to verify signature of received EA data due to exception: " + e.getMessage();
            logInvalidSignatureOfEaData(tenantId, electionEventId, adminBoardId, additionalInfo);
        }
        return valid;
    }

    private void logInvalidSignatureOfEaData(final String tenantId, final String electionEventId,
            final String adminBoardId, final String additionalInfo) {
        secureLoggingWriter.log(Level.ERROR,
            new LogContent.LogContentBuilder()
                .logEvent(ExtendedAuthenticationLogEvents.EASIGVER_SIGNATURE_VERIFICATION_ERROR).objectId("")
                .user(tenantId).electionEvent(electionEventId)
                .additionalInfo(ExtendedAuthenticationLogConstants.INFO_ADMIN_BOARD_ID, adminBoardId)
                .additionalInfo(ExtendedAuthenticationLogConstants.ERR_DESC, additionalInfo)
                .additionalInfo(ExtendedAuthenticationLogConstants.INFO_TRACK_ID, trackIdInstance.getTrackId())
                .createLogInfo());
    }

    private PublicKey getAdminBoardPublicKey(final String adminBoardId) throws GeneralCryptoLibException, RetrofitException {
        LOG.info("Fetching the administration board certificate");
        String name = ADMINISTRATION_BOARD_CN_PREFIX + adminBoardId;
        CertificateEntity entity = remoteCertificateService.getAdminBoardCertificate(name);
        String content = entity.getCertificateContent();
        Certificate certificate = PemUtils.certificateFromPem(content);
        return certificate.getPublicKey();
    }

    /**
     * {@inheritDoc}
     */
    private void saveExtendedAuthenticationFileContents(final Path extendedAuthenticationFilePath,
            final String tenantId, final String electionEventId, final String adminBoardId) throws IOException {
        try {
            try (Reader reader = newBufferedReader(extendedAuthenticationFilePath, StandardCharsets.UTF_8);
                    MappingIterator<ExtendedAuthentication> iterator =
                        ObjectMappers.readCsv(reader, ExtendedAuthentication.class, "authId", "extraParam",
                            "encryptedStartVotingKey", "electionEvent", "salt", "credentialId")) {

                TransactionalAction<Void> action =
                    context -> saveExtendedAuthenticationByBatch(tenantId, iterator, BATCH_SIZE);

                while (iterator.hasNext()) {
                    transactionController.doInNewTransaction(action);
                }
            }
            LOG.info("Extended authentication data saved for electionEventId: {}, tenantId: {}, and adminBoardId: {}.",
                electionEventId, tenantId, adminBoardId);

        } catch (TransactionalActionException e) {
            // debug because this exception should not be logged in production
            LOG.debug(e.getMessage(), e);
            LOG.warn(
                "Extended authentication data - duplicate entry for electionEventId: {}, tenantId: {}, and adminBoardId: {}.",
                electionEventId, tenantId, adminBoardId);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void updateExistingExtendedAuthentication(ExtendedAuthentication extendedAuthentication)
            throws EntryPersistenceException {

        extendedAuthenticationRepository.update(extendedAuthentication);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void saveNewExtendedAuthentication(ExtendedAuthentication extendedAuthentication)
            throws DuplicateEntryException {
        extendedAuthenticationRepository.save(extendedAuthentication);
    }

    @Override
    public void findAndWriteVotingCardsWithFailedAuthentication(final String tenantId, final String electionEventId,
            final OutputStream stream) throws IOException {
        final List<ExtendedAuthentication> extendedAuthentications = extendedAuthenticationRepository
            .findAllExceededExtendedAuthentication(tenantId, electionEventId, MAX_ALLOWED_NUMBER_OF_ATTEMPTS);

        try (FailedAuthenticationVotingCardsItemWriter writer = new FailedAuthenticationVotingCardsItemWriter(stream)) {
            extendedAuthentications.stream().map(this::getAssociatedVotingCard).filter(Objects::nonNull)
                .forEach(writer::write);
        }
    }

    private FailedAuthenticationVotingCardItem getAssociatedVotingCard(final ExtendedAuthentication ea) {
        // this may become a performance problem IF we have a lot of credentials that have been 'blocked' due to
        // too many authentication failures. IF we get to that point we may need to refactor this to get information in
        // bulk
        final Optional<VoterInformation> voterInformation = voterMaterialService
            .getVoterInformationByCredentialId(ea.getTenantId(), ea.getElectionEvent(), ea.getCredentialId());
        return voterInformation
            .map(vi -> new FailedAuthenticationVotingCardItem(ea.getCredentialId(), vi.getVotingCardId())).orElse(null);
    }

    /**
     * Private method that saves a defined number (batchSize) of new Extended Authentication entities from a generated
     * iterator.
     * 
     * @param tenantId
     *            The Tenant Id.
     * @param iterator
     *            An iterator containing the new Extended Authentication entities to be saved.
     * @param batchSize
     *            The number of elements to be saved in this batch.
     * @return Nothing to return but a Void object expected by the transaction Controller.
     */
    private Void saveExtendedAuthenticationByBatch(final String tenantId,
            final MappingIterator<ExtendedAuthentication> iterator, final int batchSize) {

        for (int i = 0; i < batchSize && iterator.hasNext(); i++) {

            ExtendedAuthentication extendedAuthentication = iterator.next();
            extendedAuthentication.setTenantId(tenantId);
            extendedAuthentication.setAttempts(0);

            try {
                this.saveNewExtendedAuthentication(extendedAuthentication);

            } catch (DuplicateEntryException e) {
                LOG.warn(
                    "Duplicate Warning: Error trying to save a new Extended Authentication with authId={} because it already exists.",
                    extendedAuthentication.getAuthId(), e);
            }
        }
        return null;
    }

    private boolean checkIfChallengeMatches(final String providedExtraParam, final String salt,
            final String saltedExtraParam) throws GeneralCryptoLibException {

        final byte[] decodedSalt = toByteFromNullableString(salt);
        final byte[] saltedProvidedExtraParam = calculateHashFromDataAndSalt(providedExtraParam, decodedSalt);
        final byte[] decodedSaltedExtraParam = toByteFromNullableString(saltedExtraParam);
        return Arrays.equals(saltedProvidedExtraParam, decodedSaltedExtraParam);
    }

    private byte[] toByteFromNullableString(String s) {
        byte[] result;
        if (s == null || s.isEmpty()) {
            result = new byte[0];
        } else {
            result = Base64.getDecoder().decode(s);
        }
        return result;
    }

    private byte[] calculateHashFromDataAndSalt(String providedExtraParam, final byte[] salt)
            throws GeneralCryptoLibException {

        if (providedExtraParam == null || providedExtraParam.isEmpty())
            return new byte[0];

		String providedExtraParamPadded = padExtraParameter(providedExtraParam);
		final CryptoAPIPBKDFDeriver derived = primitivesService.getPBKDFDeriver();
		final CryptoAPIDerivedKey cryptoAPIDerivedKeyPIN = derived.deriveKey(providedExtraParamPadded.toCharArray(),
				salt);
		return cryptoAPIDerivedKeyPIN.getEncoded();
	}

    private String padExtraParameter(String providedExtraParam) {
    	String providedExtraParamPadded;
        
    	if (providedExtraParam.length() < MIN_EXTRA_PARAM_LENGTH) {
        	providedExtraParamPadded = leftPadding(providedExtraParam, MIN_EXTRA_PARAM_LENGTH);
        } else {
        	providedExtraParamPadded = providedExtraParam;
        }
    	
        return providedExtraParamPadded;
    }

    private String leftPadding(final String s, final int n) {
        String format = "%1$" + n + "s";
        return String.format(format, s);
    }

    private ExtendedAuthentication createNewEA(final String newSVK, final String newAuthId,
            final ExtendedAuthentication extendedAuthentication) {
        ExtendedAuthentication newEA = new ExtendedAuthentication();
        newEA.setAttempts(extendedAuthentication.getAttempts());
        newEA.setAuthId(newAuthId);
        newEA.setElectionEvent(extendedAuthentication.getElectionEvent());
        newEA.setEncryptedStartVotingKey(newSVK);
        newEA.setExtraParam(extendedAuthentication.getExtraParam());
        newEA.setSalt(extendedAuthentication.getSalt());
        newEA.setTenantId(extendedAuthentication.getTenantId());
        newEA.setCredentialId(extendedAuthentication.getCredentialId());
        return newEA;
    }
}
