/*
 * Copyright 2018 Red Hat, Inc. and/or its affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.kie.processmigration.service.impl;

import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import javax.transaction.Transactional;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;

import org.apache.commons.lang3.StringUtils;
import org.kie.processmigration.model.Execution.ExecutionStatus;
import org.kie.processmigration.model.Execution.ExecutionType;
import org.kie.processmigration.model.Migration;
import org.kie.processmigration.model.MigrationDefinition;
import org.kie.processmigration.model.MigrationReport;
import org.kie.processmigration.model.Plan;
import org.kie.processmigration.model.exceptions.InvalidKieServerException;
import org.kie.processmigration.model.exceptions.InvalidMigrationException;
import org.kie.processmigration.model.exceptions.MigrationNotFoundException;
import org.kie.processmigration.model.exceptions.PlanNotFoundException;
import org.kie.processmigration.model.exceptions.ProcessNotFoundException;
import org.kie.processmigration.model.exceptions.ReScheduleException;
import org.kie.processmigration.service.KieService;
import org.kie.processmigration.service.MigrationService;
import org.kie.processmigration.service.PlanService;
import org.kie.processmigration.service.SchedulerService;
import org.kie.processmigration.service.TransactionHelper;
import org.kie.server.api.model.admin.MigrationReportInstance;
import org.kie.server.api.model.instance.ProcessInstance;
import org.kie.server.client.QueryServicesClient;
import org.kie.server.client.admin.ProcessAdminServicesClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@ApplicationScoped
public class MigrationServiceImpl implements MigrationService {

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

    private static final List<Integer> QUERY_PROCESS_INSTANCE_STATUSES = Arrays.asList(org.kie.api.runtime.process.ProcessInstance.STATE_ACTIVE);
    public static final Integer QUERY_PAGE_SIZE = 100;

    @PersistenceContext
    private EntityManager em;

    @Inject
    private PlanService planService;

    @Inject
    private KieService kieService;

    @Inject
    private SchedulerService schedulerService;

    @Inject
    private TransactionHelper txHelper;

    @Override
    public Migration get(Long id) throws MigrationNotFoundException {
        TypedQuery<Migration> query = em.createNamedQuery("Migration.findById", Migration.class);
        query.setParameter("id", id);
        try {
            return query.getSingleResult();
        } catch (NoResultException e) {
            throw new MigrationNotFoundException(id);
        }
    }

    @Override
    public List<MigrationReport> getResults(Long id) throws MigrationNotFoundException {
        Migration m = get(id);
        TypedQuery<MigrationReport> query = em.createNamedQuery("MigrationReport.findByMigrationId", MigrationReport.class);
        query.setParameter("id", m.getId());
        return query.getResultList();
    }

    @Override
    public List<Migration> findAll() {
        return em.createNamedQuery("Migration.findAll", Migration.class).getResultList();
    }

    @Override
    public Migration submit(MigrationDefinition definition) throws InvalidMigrationException {
        validateDefinition(definition);
        Migration migration = txHelper.withTransaction(() -> {
            Migration m = new Migration(definition);
            em.persist(m);
            return m;
        });
        if (ExecutionType.SYNC.equals(definition.getExecution().getType())) {
            migrate(migration);
        } else {
            validatePlanExecution(definition);
            schedulerService.scheduleMigration(migration);
        }
        return migration;
    }

    @Override
    @Transactional
    public Migration delete(Long id) throws MigrationNotFoundException {
        Migration migration = get(id);
        em.remove(migration);
        return migration;
    }

    @Override
    public Migration update(Long id, MigrationDefinition definition) throws MigrationNotFoundException, ReScheduleException, InvalidMigrationException {
        validateDefinition(definition);
        Migration migration = get(id);
        if (!ExecutionStatus.SCHEDULED.equals(migration.getStatus())) {
            throw new ReScheduleException("The migration is not scheduled and cannot be re-scheduled");
        }
        if (ExecutionType.SYNC.equals(definition.getExecution().getType())) {
            throw new ReScheduleException("The migration execution type MUST be ASYNC");
        }
        migration.setDefinition(definition);
        txHelper.withTransaction(() -> em.merge(migration));
        schedulerService.reScheduleMigration(migration);
        return migration;
    }

    @Override
    public Migration migrate(Migration migration) throws InvalidMigrationException {
        try {
            Plan plan = planService.get(migration.getDefinition().getPlanId());
            validatePlanExecution(migration.getDefinition(), plan);
            if (ExecutionStatus.CREATED.equals(migration.getStatus()) || ExecutionStatus.SCHEDULED.equals(migration.getStatus())) {
                migration.start();
            }
            AtomicBoolean hasErrors = new AtomicBoolean(false);
            List<Long> instanceIds = getInstancesToMigrate(migration);
            ProcessAdminServicesClient adminService = kieService.getProcessAdminServicesClient(migration.getDefinition().getKieserverId());
            QueryServicesClient queryService = kieService.getQueryServicesClient(migration.getDefinition().getKieserverId());
            for (Long instanceId : instanceIds) {
                boolean successful = migrateInstance(instanceId, migration, plan, adminService, queryService);
                if (!hasErrors.get() && !Boolean.TRUE.equals(successful)) {
                    hasErrors.set(Boolean.TRUE);
                }
            }
            migration.complete(hasErrors.get());
        } catch (ProcessNotFoundException e) {
            migration.fail(e);
            throw e;
        } catch (PlanNotFoundException e) {
            migration.fail(e);
            throw new InvalidMigrationException("The provided plan id does not exist: " + migration.getDefinition().getPlanId());
        } catch (Exception e) {
            logger.warn("Migration failed", e);
            migration.fail(e);
        } finally {
            txHelper.withTransaction(() -> em.merge(migration));
            if (ExecutionType.ASYNC.equals(migration.getDefinition().getExecution().getType()) &&
                migration.getDefinition().getExecution().getCallbackUrl() != null) {
                doCallback(migration);
            }
        }
        return migration;
    }

    private boolean migrateInstance(Long instanceId, Migration migration, Plan plan, ProcessAdminServicesClient adminService, QueryServicesClient queryService) {
        MigrationReportInstance reportInstance = null;
        try {
            ProcessInstance pi = queryService.findProcessInstanceById(instanceId);
            if (pi != null && pi.getContainerId().equals(plan.getSourceContainerId())) {
                reportInstance = adminService.migrateProcessInstance(
                                                                     plan.getSourceContainerId(),
                                                                     instanceId,
                                                                     plan.getTargetContainerId(),
                                                                     plan.getTargetProcessId(),
                                                                     plan.getMappings());
            } else {
                reportInstance = buildReport(instanceId);
                reportInstance.setLogs(Arrays.asList("Instance did not exist in source container. Migration skipped"));
                logger.debug("Process Instance {} did not exist in source container with id {}", instanceId, plan.getSourceContainerId());
            }
        } catch (Exception e) {
            logger.warn("Unable to migrate instanceID: " + instanceId, e);
            reportInstance = buildReportFromError(instanceId, e);
        }
        final MigrationReport report = new MigrationReport(migration.getId(), reportInstance);
        return txHelper.withTransaction(() -> {
            em.persist(report);
            return report;
        }).getSuccessful();
    }

    private void doCallback(Migration migration) {
        URI callbackURI = null;
        try {
            callbackURI = migration.getDefinition().getExecution().getCallbackUrl();
            Response response = ClientBuilder.newClient()
                                             .target(callbackURI)
                                             .request(MediaType.APPLICATION_JSON)
                                             .buildPost(Entity.json(migration))
                                             .invoke();
            if (Status.OK.getStatusCode() == response.getStatus()) {
                logger.debug("Migration [{}] - Callback to {} replied successfully", migration.getId(), callbackURI);
            } else {
                logger.warn("Migration [{}] - Callback to {} replied with {}", migration.getId(), callbackURI, response.getStatus());
            }
        } catch (Exception e) {
            logger.error("Migration [{}] - Callback to {} failed.", migration.getId(), callbackURI, e);
        }
    }

    public void validateDefinition(MigrationDefinition definition) throws InvalidMigrationException {
        if (definition == null) {
            throw new InvalidMigrationException("The Migration Definition must not be null");
        }
        if (definition.getPlanId() == null) {
            throw new InvalidMigrationException("The Plan ID is mandatory");
        }
        if (StringUtils.isBlank(definition.getKieserverId())) {
            throw new InvalidMigrationException("The KIE Server ID is mandatory");
        }
        if (!kieService.getConfigs().containsKey(definition.getKieserverId())) {
            throw new InvalidKieServerException(definition.getKieserverId());
        }
    }

    private void validatePlanExecution(MigrationDefinition definition) throws InvalidMigrationException {
        try {
            validatePlanExecution(definition, planService.get(definition.getPlanId()));
        } catch (PlanNotFoundException e) {
            throw new InvalidMigrationException("Plan not found with ID: " + definition.getPlanId());
        }
    }

    private void validatePlanExecution(MigrationDefinition definition, Plan plan) throws InvalidMigrationException {
        if (!kieService.existsProcessDefinition(plan.getSourceContainerId(), plan.getTargetProcessId(), definition.getKieserverId())) {
            throw new ProcessNotFoundException(plan.getSourceContainerId());
        }
    }

    private List<Long> getInstancesToMigrate(Migration migration) throws InvalidKieServerException, PlanNotFoundException {
        List<Long> instanceIds = migration.getDefinition().getProcessInstanceIds();
        List<Long> migratedInstances = new ArrayList<>();
        if (migration.getReports() != null && !migration.getReports().isEmpty()) {
            migration.getReports().stream().map(r -> r.getProcessInstanceId()).forEach(id -> migratedInstances.add(id));
        }
        Plan plan = planService.get(migration.getDefinition().getPlanId());
        if (instanceIds == null || instanceIds.isEmpty()) {
            boolean allFetched = false;
            int page = 0;
            while (!allFetched) {
                List<ProcessInstance> instances = kieService.getQueryServicesClient(migration.getDefinition().getKieserverId())
                                                            .findProcessInstancesByContainerId(plan.getSourceContainerId(), QUERY_PROCESS_INSTANCE_STATUSES, page++, QUERY_PAGE_SIZE);

                instances.stream().forEach(p -> instanceIds.add(p.getId()));
                if (instances.size() < QUERY_PAGE_SIZE) {
                    allFetched = true;
                }
            }
        }
        return instanceIds.stream().filter(id -> !migratedInstances.contains(id)).collect(Collectors.toList());
    }

    private MigrationReportInstance buildReport(Long instanceId) {
        MigrationReportInstance reportInstance = new MigrationReportInstance();
        reportInstance.setSuccessful(true);
        reportInstance.setProcessInstanceId(instanceId);
        reportInstance.setStartDate(new Date());
        reportInstance.setEndDate(new Date());
        return reportInstance;
    }

    private MigrationReportInstance buildReportFromError(Long instanceId, Exception e) {
        MigrationReportInstance reportInstance = buildReport(instanceId);
        reportInstance.setSuccessful(false);
        reportInstance.setLogs(Arrays.asList(e.getMessage()));
        return reportInstance;
    }

}
