package org.hso.ecommerce.controller.cronjob;

import java.sql.Timestamp;
import java.util.Calendar;
import java.util.Collections;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.annotation.PostConstruct;

import org.hso.ecommerce.action.booking.CreateBookingAction;
import org.hso.ecommerce.action.cronjob.ReadSupplierDataAction;
import org.hso.ecommerce.action.cronjob.ReadSupplierDataAction.ArticleIdentifier;
import org.hso.ecommerce.action.cronjob.ReorderAction;
import org.hso.ecommerce.action.cronjob.UpdateOffersAction;
import org.hso.ecommerce.entities.booking.Booking;
import org.hso.ecommerce.entities.booking.BookingAccountEntry;
import org.hso.ecommerce.entities.booking.BookingReason;
import org.hso.ecommerce.entities.cron.BackgroundJob;
import org.hso.ecommerce.entities.shop.Article;
import org.hso.ecommerce.entities.supplier.ArticleOffer;
import org.hso.ecommerce.entities.supplier.Supplier;
import org.hso.ecommerce.entities.supplier.SupplierOrder;
import org.hso.ecommerce.repos.booking.BookingAccountEntryRepository;
import org.hso.ecommerce.repos.booking.BookingRepository;
import org.hso.ecommerce.repos.cronjob.BackgroundJobRepository;
import org.hso.ecommerce.repos.shop.ArticleRepository;
import org.hso.ecommerce.repos.shop.CustomerOrderRepository;
import org.hso.ecommerce.repos.supplier.ArticleOfferRepository;
import org.hso.ecommerce.repos.supplier.SupplierOrderRepository;
import org.hso.ecommerce.repos.supplier.SupplierRepository;
import org.hso.ecommerce.repos.warehouse.WarehouseBookingPositionSlotEntryRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

interface ICronjob {
    /**
     * Calculate the earliest cronjob execution time that happens after the given reference time.
     *
     * @param reference Position in time to start searching. The implementor is allowed to modify the reference time.
     * @return A new Calendar instance (or the same) containing the time for next execution.
     */
    Calendar nextExecution(Calendar reference);

    /**
     * Calculate the latest cronjob execution time that happens before or exactly at the given refernce time.
     *
     * @param reference Position in time to start searching. The implementor is allowed to modify the reference time.
     * @return A new Calendar instance (or the same) containing the time of the last execution.
     */
    Calendar previousExecution(Calendar reference);

    /**
     * Execute this cronjob.
     *
     * @param time       The point in time this execution was scheduled. In case of a missed cronjob, the actual time of
     *                   this call might be much later.
     * @param controller Back-reference that allows to use repositories.
     */
    void executeAt(Calendar time, CronjobController controller);
}

@Component
class Reorder implements ICronjob {
    @Override
    public Calendar nextExecution(Calendar reference) {
        if (reference.get(Calendar.HOUR_OF_DAY) >= 8) {
            reference.add(Calendar.DAY_OF_MONTH, 1);
        }
        reference.set(Calendar.HOUR_OF_DAY, 8);
        reference.set(Calendar.MINUTE, 0);
        reference.set(Calendar.SECOND, 0);
        reference.set(Calendar.MILLISECOND, 0);
        return reference;
    }

    @Override
    public Calendar previousExecution(Calendar reference) {
        if (reference.get(Calendar.HOUR_OF_DAY) < 8) {
            reference.add(Calendar.DAY_OF_MONTH, -1);
        }
        reference.set(Calendar.HOUR_OF_DAY, 8);
        reference.set(Calendar.MINUTE, 0);
        reference.set(Calendar.SECOND, 0);
        reference.set(Calendar.MILLISECOND, 0);
        return reference;
    }

    /**
     * Calculates the amount of ordered articles by customers for the given article type in the time between begin and
     * end.
     *
     * @param article The article to search orders for.
     * @param begin   The start time for the search (included)
     * @param end     The end time for the search (excluded)
     * @return The number of articles that were ordered by customers in the given range.
     */
    private Integer getOrderedAmounts(Article article, Calendar begin, Calendar end, CronjobController controller) {
        return controller.customerOrderRepository.countOrdersOfArticleInTimespan(
                article.id,
                new Timestamp(begin.getTimeInMillis()), new Timestamp(end.getTimeInMillis()));
    }

    /**
     * Calculates the amount of ordered articles by customers for the given article type in the three days before the
     * given reference time. The return-array contains 3 fields: Index 0: Orders 72 to 48 hours ago; Index 1: Orders 48
     * to 24 hours ago; Index 2: Orders 24 to 0 hours ago.
     *
     * @param article The article for which the customer orders are checked.
     * @param time    The reference time to use for calculation of the last orders.
     * @return A 3-element array containing the orders of the last three days.
     */
    private Integer[] getOrderedAmounts(Article article, Calendar time, CronjobController controller) {
        Calendar oneDayBefore = (Calendar) time.clone();
        oneDayBefore.add(Calendar.DAY_OF_MONTH, -1);
        Calendar twoDaysBefore = (Calendar) time.clone();
        twoDaysBefore.add(Calendar.DAY_OF_MONTH, -2);
        Calendar threeDaysBefore = (Calendar) time.clone();
        threeDaysBefore.add(Calendar.DAY_OF_MONTH, -3);

        return new Integer[] { //
                getOrderedAmounts(article, threeDaysBefore, twoDaysBefore, controller), //
                getOrderedAmounts(article, twoDaysBefore, oneDayBefore, controller), //
                getOrderedAmounts(article, oneDayBefore, time, controller), //
        };
    }

    private HashMap<ArticleIdentifier, ArticleOffer> mapArticleOffers(List<ArticleOffer> articleOffers) {
        HashMap<ArticleIdentifier, ArticleOffer> map = new HashMap<>();
        for (ArticleOffer articleOffer : articleOffers) {
            ArticleIdentifier identifier = new ArticleIdentifier(articleOffer.manufacturer, articleOffer.articleNumber);
            map.put(identifier, articleOffer);
        }
        return map;
    }

    @Override
    public void executeAt(Calendar time, CronjobController controller) {
        List<Supplier> suppliers = controller.supplierRepository.findAll();
        ReadSupplierDataAction.Result supplierData = new ReadSupplierDataAction(suppliers).finish();

        // Save the new offers in the database
        List<ArticleOffer> allOffers = controller.articleOfferRepository.findAll();
        allOffers = new UpdateOffersAction(allOffers, supplierData.cheapestOffer).finish();
        controller.articleOfferRepository.saveAll(allOffers);

        HashMap<ArticleIdentifier, ArticleOffer> mappedOffers = mapArticleOffers(allOffers);

        // Reorder
        List<Article> allArticles = controller.articleRepository.findAll();
        for (Article article : allArticles) {
            Integer[] orderedAmounts = getOrderedAmounts(article, time, controller);

            Integer undeliveredReorders = controller.supplierOrderRepository
                    .countUndeliveredReorders(article.related.articleNumber);

            int amountInStock = controller.warehouseBookingPositionSlotEntryRepository.getArticleStock(article.id)
                    .orElse(0);

            ReorderAction action = new ReorderAction(article, orderedAmounts,
                    undeliveredReorders,
                    amountInStock,
                    supplierData.cheapestOffer, mappedOffers);
            SupplierOrder order = action.finish();
            if (order != null) {
                controller.supplierOrderRepository.save(order);

                // Create bookings for this order
                int netPrice = order.totalPriceNet;
                int vatPercent = order.ordered.vatPercent;
                int vatAmount = netPrice * vatPercent / 100;
                int grossPrice = netPrice + vatAmount;

                // Obligation towards the supplier
                BookingAccountEntry mainAccount = controller.bookingAccountEntryRepository.getByMain()
                        .orElseGet(BookingAccountEntry::newMain);
                BookingAccountEntry supplierAccount = controller.bookingAccountEntryRepository
                        .getBySupplier(order.supplier.id)
                        .orElseGet(() -> BookingAccountEntry.newSupplier(order.supplier));
                BookingReason obligationReason = new BookingReason(order);
                Booking obligationBooking = new CreateBookingAction(mainAccount,
                        supplierAccount,
                        obligationReason,
                        grossPrice).finish();
                controller.bookingRepository.save(obligationBooking);

                // Input Tax
                BookingAccountEntry vatAccount = controller.bookingAccountEntryRepository.getByVat()
                        .orElseGet(BookingAccountEntry::newVat);
                mainAccount = controller.bookingAccountEntryRepository.getByMain().get();
                BookingReason inputTaxReason = new BookingReason(order);
                Booking inputTaxBooking = new CreateBookingAction(vatAccount, mainAccount, inputTaxReason, vatAmount)
                        .finish();
                controller.bookingRepository.save(inputTaxBooking);
            }
        }
    }
}

class ScheduledCronjob {
    public final Calendar executionTime;
    public final ICronjob cronjob;
    public final BackgroundJob model;

    public ScheduledCronjob(Calendar executionTime, ICronjob cronjob, BackgroundJob model) {
        this.executionTime = executionTime;
        this.cronjob = cronjob;
        this.model = model;
    }
}

@Component
class CronjobController {
    private static final Logger log = LoggerFactory.getLogger(CronjobController.class);

    private static final Map<String, ICronjob> cronjobs = getCronjobs();

    @Autowired
    private final BackgroundJobRepository cronjobRepository = null;

    @Autowired
    final ArticleRepository articleRepository = null;

    @Autowired
    final ArticleOfferRepository articleOfferRepository = null;

    @Autowired
    final CustomerOrderRepository customerOrderRepository = null;

    @Autowired
    final BookingRepository bookingRepository = null;

    @Autowired
    final BookingAccountEntryRepository bookingAccountEntryRepository = null;

    @Autowired
    final WarehouseBookingPositionSlotEntryRepository warehouseBookingPositionSlotEntryRepository = null;

    @Autowired
    final SupplierRepository supplierRepository = null;

    @Autowired
    final SupplierOrderRepository supplierOrderRepository = null;

    private static Map<String, ICronjob> getCronjobs() {
        HashMap<String, ICronjob> map = new HashMap<>();

        // Register all existing cronjobs
        map.put(BackgroundJob.JOB_REORDER, new Reorder());

        return Collections.unmodifiableMap(map);
    }

    private ScheduledCronjob getNextCronjob() {
        Calendar currentTime = new GregorianCalendar();
        Iterable<BackgroundJob> jobs = cronjobRepository.findAll();
        HashMap<String, BackgroundJob> alreadyExecuted = new HashMap<>();
        for (BackgroundJob job : jobs) {
            alreadyExecuted.put(job.jobName, job);
        }
        ScheduledCronjob earliestJob = null;
        for (Entry<String, ICronjob> entry : cronjobs.entrySet()) {
            ScheduledCronjob resultingJob;
            BackgroundJob dbEntry = alreadyExecuted.get(entry.getKey());
            if (dbEntry != null) {
                Calendar previousExecution = new GregorianCalendar();
                previousExecution.setTimeInMillis(dbEntry.lastExecution.getTime());
                Calendar followingSchedule = entry.getValue().nextExecution((Calendar) previousExecution.clone());
                Calendar lastSchedule = entry.getValue().previousExecution((Calendar) currentTime.clone());
                if (lastSchedule.getTimeInMillis() > followingSchedule.getTimeInMillis()) {
                    // This happens, if more than one execution was missed.
                    // In this case, run the job only once.
                    followingSchedule = lastSchedule;
                }
                resultingJob = new ScheduledCronjob(followingSchedule, entry.getValue(), dbEntry);
            } else {
                // This cronjob has never been executed before.
                Calendar lastScheduleTime = entry.getValue().previousExecution((Calendar) currentTime.clone());
                BackgroundJob model = new BackgroundJob();
                model.jobName = entry.getKey();
                resultingJob = new ScheduledCronjob(lastScheduleTime, entry.getValue(), model);
            }

            // Look for the job with earliest executionTime - it will run next
            if (earliestJob == null
                    || resultingJob.executionTime.getTimeInMillis() < earliestJob.executionTime.getTimeInMillis()) {
                earliestJob = resultingJob;
            }
        }
        return earliestJob;
    }

    private void runCronjobExecutionLoop() {
        Thread.currentThread().setName("Cronjob");
        try {
            while (true) {
                ScheduledCronjob nextJob = getNextCronjob();
                if (nextJob == null) {
                    // In case there are no cronjobs
                    return;
                }
                long waitingTime = nextJob.executionTime.getTimeInMillis() - System.currentTimeMillis();
                if (waitingTime > 0) {
                    Thread.sleep(waitingTime);
                }

                try {
                    nextJob.cronjob.executeAt(nextJob.executionTime, this);
                } catch (Throwable t) {
                    log.error("Failed to execute cronjob " + nextJob.cronjob.getClass() + ":");
                    t.printStackTrace();
                }

                nextJob.model.lastExecution = new Timestamp(nextJob.executionTime.getTimeInMillis());
                cronjobRepository.save(nextJob.model);
            }
        } catch (InterruptedException e) {
            log.error("The cronjob execution thread has been interrupted");
        }
    }

    @PostConstruct
    public void onPostConstruct() {
        new Thread(this::runCronjobExecutionLoop).start();
    }
}
