/*
 * The Kuali Financial System, a comprehensive financial management system for higher education.
 *
 * Copyright 2005-2016 The Kuali Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.kuali.kfs.gl.service.impl;

import org.apache.commons.lang.StringUtils;
import org.kuali.kfs.coa.businessobject.ObjectCode;
import org.kuali.kfs.coa.service.AccountService;
import org.kuali.kfs.coa.service.ObjectLevelService;
import org.kuali.kfs.coa.service.ObjectTypeService;
import org.kuali.kfs.fp.document.YearEndDocument;
import org.kuali.kfs.gl.batch.dataaccess.SufficientFundsDao;
import org.kuali.kfs.gl.businessobject.SufficientFundBalances;
import org.kuali.kfs.gl.businessobject.SufficientFundRebuild;
import org.kuali.kfs.gl.businessobject.Transaction;
import org.kuali.kfs.gl.dataaccess.SufficientFundBalancesDao;
import org.kuali.kfs.gl.service.SufficientFundsService;
import org.kuali.kfs.gl.service.SufficientFundsServiceConstants;
import org.kuali.kfs.krad.service.BusinessObjectService;
import org.kuali.kfs.krad.util.ObjectUtils;
import org.kuali.kfs.sys.KFSConstants;
import org.kuali.kfs.sys.KFSPropertyConstants;
import org.kuali.kfs.sys.businessobject.SufficientFundsItem;
import org.kuali.kfs.sys.businessobject.SystemOptions;
import org.kuali.kfs.sys.document.GeneralLedgerPostingDocument;
import org.kuali.kfs.sys.service.GeneralLedgerPendingEntryService;
import org.kuali.kfs.sys.service.OptionsService;
import org.kuali.rice.core.api.config.property.ConfigurationService;
import org.kuali.rice.core.api.util.type.KualiDecimal;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * The base implementation of SufficientFundsService
 */
@Transactional
public class SufficientFundsServiceImpl implements SufficientFundsService, SufficientFundsServiceConstants {
    private static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(SufficientFundsServiceImpl.class);

    private AccountService accountService;
    private ObjectLevelService objectLevelService;
    private ConfigurationService kualiConfigurationService;
    private SufficientFundsDao sufficientFundsDao;
    private SufficientFundBalancesDao sufficientFundBalancesDao;
    private OptionsService optionsService;
    private GeneralLedgerPendingEntryService generalLedgerPendingEntryService;
    private BusinessObjectService businessObjectService;
    private ObjectTypeService objectTypeService;

    /**
     * Default constructor
     */
    public SufficientFundsServiceImpl() {
        super();
    }

    /**
     * This operation derives the acct_sf_finobj_cd which is used to populate the General Ledger Pending entry table, so that later
     * we can do Suff Fund checking against that entry
     *
     * @param financialObject            the object code being checked against
     * @param accountSufficientFundsCode the kind of sufficient funds checking turned on in this system
     * @return the object code that should be used for the sufficient funds inquiry, or a blank String
     * @see org.kuali.kfs.gl.service.SufficientFundsService#getSufficientFundsObjectCode(org.kuali.kfs.coa.businessobject.ObjectCode,
     * java.lang.String)
     */
    @Override
    public String getSufficientFundsObjectCode(ObjectCode financialObject, String accountSufficientFundsCode) {
        LOG.debug("getSufficientFundsObjectCode() started");

        if (KFSConstants.SF_TYPE_NO_CHECKING.equals(accountSufficientFundsCode)) {
            return KFSConstants.NOT_AVAILABLE_STRING;
        } else if (KFSConstants.SF_TYPE_ACCOUNT.equals(accountSufficientFundsCode)) {
            return "    ";
        } else if (KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(accountSufficientFundsCode)) {
            return "    ";
        } else if (KFSConstants.SF_TYPE_OBJECT.equals(accountSufficientFundsCode)) {
            return financialObject.getFinancialObjectCode();
        } else if (KFSConstants.SF_TYPE_LEVEL.equals(accountSufficientFundsCode)) {
            return financialObject.getFinancialObjectLevelCode();
        } else if (KFSConstants.SF_TYPE_CONSOLIDATION.equals(accountSufficientFundsCode)) {
            financialObject.refreshReferenceObject("financialObjectLevel");
            return financialObject.getFinancialObjectLevel().getFinancialConsolidationObjectCode();
        } else {
            throw new IllegalArgumentException("Invalid Sufficient Funds Code: " + accountSufficientFundsCode);
        }
    }

    /**
     * Purge the sufficient funds balance table by year/chart
     *
     * @param chart the chart of sufficient fund balances to purge
     * @param year  the fiscal year of sufficient fund balances to purge
     */
    @Override
    public void purgeYearByChart(String chart, int year) {
        sufficientFundsDao.purgeYearByChart(chart, year);
    }

    /**
     * Checks for sufficient funds on a single document
     *
     * @param document document to check
     * @return Empty List if has sufficient funds for all accounts, List of SufficientFundsItem if not
     * @see org.kuali.kfs.gl.service.SufficientFundsService#checkSufficientFunds(org.kuali.rice.krad.document.FinancialDocument)
     */
    @Override
    public List<SufficientFundsItem> checkSufficientFunds(GeneralLedgerPostingDocument document) {
        LOG.debug("checkSufficientFunds() started");

        return checkSufficientFunds(document.getPendingLedgerEntriesForSufficientFundsChecking());
    }

    /**
     * checks to see if a document is a <code>YearEndDocument</code>
     *
     * @param documentClass the class of a Document to check
     * @return true if the class implements <code>YearEndDocument</code>
     */
    @SuppressWarnings("unchecked")
    protected boolean isYearEndDocument(Class documentClass) {
        return YearEndDocument.class.isAssignableFrom(documentClass);
    }

    /**
     * Checks for sufficient funds on a list of transactions
     *
     * @param transactions list of transactions
     * @return Empty List if has sufficient funds for all accounts, List of SufficientFundsItem if not
     * @see org.kuali.kfs.gl.service.SufficientFundsService#checkSufficientFunds(java.util.List)
     */
    @SuppressWarnings("unchecked")
    @Override
    public List<SufficientFundsItem> checkSufficientFunds(List<? extends Transaction> transactions) {
        LOG.debug("checkSufficientFunds() started");

        for (Transaction e : transactions) {
            e.refreshNonUpdateableReferences();
        }

        final List<String> transactionDocumentNumbers = transactions.stream()
            .map(Transaction::getDocumentNumber)
            .distinct()
            .collect(Collectors.toList());

        List<SufficientFundsItem> summaryItems = summarizeTransactions(transactions);
        for (Iterator iter = summaryItems.iterator(); iter.hasNext(); ) {
            SufficientFundsItem item = (SufficientFundsItem) iter.next();
            if (LOG.isDebugEnabled()) {
                LOG.debug("checkSufficientFunds() " + item.toString());
            }
            if (hasSufficientFundsOnItem(item, transactionDocumentNumbers)) {
                iter.remove();
            }
        }

        return summaryItems;
    }

    /**
     * For each transaction, fetches the appropriate sufficient funds item to check against
     *
     * @param transactions a list of Transactions
     * @return a List of corresponding SufficientFundsItem
     */
    @SuppressWarnings("unchecked")
    protected List<SufficientFundsItem> summarizeTransactions(List<? extends Transaction> transactions) {
        Map<String, SufficientFundsItem> items = new HashMap<>();

        SystemOptions currentYear = optionsService.getCurrentYearOptions();

        // loop over the given transactions, grouping into SufficientFundsItem objects
        // which are keyed by the appropriate chart/account/SF type, and derived object value
        // see getSufficientFundsObjectCode() for the "object" used for grouping
        for (Iterator iter = transactions.iterator(); iter.hasNext(); ) {
            Transaction tran = (Transaction) iter.next();

            SystemOptions year = tran.getOption();
            if (year == null) {
                year = currentYear;
            }
            if (ObjectUtils.isNull(tran.getAccount())) {
                throw new IllegalArgumentException("Invalid account: " + tran.getChartOfAccountsCode() + "-" + tran.getAccountNumber());
            }
            SufficientFundsItem sfi = new SufficientFundsItem(year, tran, getSufficientFundsObjectCode(tran.getFinancialObject(), tran.getAccount().getAccountSufficientFundsCode()));
            sfi.setDocumentTypeCode(tran.getFinancialDocumentTypeCode());

            if (items.containsKey(sfi.getKey())) {
                SufficientFundsItem item = (SufficientFundsItem) items.get(sfi.getKey());
                item.add(tran);
            } else {
                items.put(sfi.getKey(), sfi);
            }
        }

        return new ArrayList<>(items.values());
    }

    /**
     * Given a sufficient funds item record, determines if there are sufficient funds available for the transaction
     *
     * @param item the item to check
     * @return true if there are sufficient funds available, false otherwise
     */
    protected boolean hasSufficientFundsOnItem(SufficientFundsItem item, List<String> transactionDocumentNumbers) {

        if (item.getAmount().equals(KualiDecimal.ZERO)) {
            LOG.debug("hasSufficientFundsOnItem() Transactions with zero amounts should pass");
            return true;
        }

        if (!item.getYear().isBudgetCheckingOptionsCode()) {
            LOG.debug("hasSufficientFundsOnItem() No sufficient funds checking");
            return true;
        }

        if (!item.getAccount().isPendingAcctSufficientFundsIndicator()) {
            LOG.debug("hasSufficientFundsOnItem() No checking on eDocs for account " + item.getAccount().getChartOfAccountsCode() + "-" + item.getAccount().getAccountNumber());
            return true;
        }

        // exit sufficient funds checking if not enabled for an account
        if (KFSConstants.SF_TYPE_NO_CHECKING.equals(item.getAccountSufficientFundsCode())) {
            LOG.debug("hasSufficientFundsOnItem() sufficient funds not enabled for account " + item.getAccount().getChartOfAccountsCode() + "-" + item.getAccount().getAccountNumber());
            return true;
        }

        List<String> expenseObjectTypes = objectTypeService.getCurrentYearExpenseObjectTypes();
        if (LOG.isDebugEnabled()) {
            // let's not even risk joining a null array unless debug is on
            LOG.debug("hasSufficientFundsOnItem() retrieved expense object types: " + StringUtils.join(expenseObjectTypes, ",") + " size: " + expenseObjectTypes.size());
        }

        if (KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(item.getAccount().getAccountSufficientFundsCode())
            && !item.getFinancialObject().getChartOfAccounts().getFinancialCashObjectCode().equals(item.getFinancialObject().getFinancialObjectCode())) {
            LOG.debug("hasSufficientFundsOnItem() SF checking is cash and transaction is not cash");
            return true;
        } else if (!KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(item.getAccount().getAccountSufficientFundsCode())
            && !expenseObjectTypes.contains(item.getFinancialObjectType().getCode())) {
            LOG.debug("hasSufficientFundsOnItem() SF checking is budget and transaction is not expense");
            return true;
        }

        Map<String, Object> keys = new HashMap<>();
        keys.put(KFSPropertyConstants.UNIVERSITY_FISCAL_YEAR, item.getYear().getUniversityFiscalYear());
        keys.put(KFSPropertyConstants.CHART_OF_ACCOUNTS_CODE, item.getAccount().getChartOfAccountsCode());
        keys.put(KFSPropertyConstants.ACCOUNT_NUMBER, item.getAccount().getAccountNumber());
        keys.put(KFSPropertyConstants.FINANCIAL_OBJECT_CODE, item.getSufficientFundsObjectCode());
        SufficientFundBalances sfBalance = (SufficientFundBalances) businessObjectService.findByPrimaryKey(SufficientFundBalances.class, keys);

        if (sfBalance == null) {
            Map criteria = new HashMap();
            criteria.put(KFSPropertyConstants.CHART_OF_ACCOUNTS_CODE, item.getAccount().getChartOfAccountsCode());
            criteria.put(KFSPropertyConstants.ACCOUNT_NUMBER_FINANCIAL_OBJECT_CODE, item.getAccount().getAccountNumber());

            Collection sufficientFundRebuilds = businessObjectService.findMatching(SufficientFundRebuild.class, criteria);
            if (sufficientFundRebuilds != null && sufficientFundRebuilds.size() > 0) {
                LOG.debug("hasSufficientFundsOnItem() No balance record and waiting on rebuild, no sufficient funds");
                return false;
            } else {
                sfBalance = new SufficientFundBalances();
                sfBalance.setAccountActualExpenditureAmt(KualiDecimal.ZERO);
                sfBalance.setAccountEncumbranceAmount(KualiDecimal.ZERO);
                sfBalance.setCurrentBudgetBalanceAmount(KualiDecimal.ZERO);
            }
        }

        KualiDecimal balanceAmount = item.getAmount();
        if (KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(item.getAccount().getAccountSufficientFundsCode())
            || item.getYear().getBudgetCheckingBalanceTypeCd().equals(item.getBalanceTyp().getCode())) {
            // We need to change the sign on the amount because the amount in the item is an increase in cash. We only care
            // about decreases in cash.

            // Also, negating if this is a balance type code of budget checking and the transaction is a budget transaction.

            balanceAmount = balanceAmount.negated();
        }

        if (balanceAmount.isNegative()) {
            LOG.debug("hasSufficientFundsOnItem() balanceAmount is negative, allow transaction to proceed");
            return true;
        }

        PendingAmounts priorYearPending = new PendingAmounts();
        // if we're checking the CASH_AT_ACCOUNT type, then we need to consider the prior year pending transactions
        // if the balance forwards have not been run
        if ((KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(item.getAccount().getAccountSufficientFundsCode()))
            && (!item.getYear().isFinancialBeginBalanceLoadInd())) {
            priorYearPending = getPriorYearSufficientFundsBalanceAmount(item);
        }

        PendingAmounts pending = getPendingBalanceAmount(item, transactionDocumentNumbers);

        KualiDecimal availableBalance = null;
        if (KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(item.getAccount().getAccountSufficientFundsCode())) {
            // if the beginning balances have not loaded for the transaction FY, pull the remaining balance from last year
            if (!item.getYear().isFinancialBeginBalanceLoadInd()) {
                availableBalance = sfBalance.getCurrentBudgetBalanceAmount()
                    .add(priorYearPending.budget) // add the remaining budget from last year (assumed to carry to this year's)
                    .add(pending.actual) // any pending expenses (remember sense is negated)
                    .subtract(sfBalance.getAccountEncumbranceAmount()) // subtract the encumbrances (not reflected in cash yet)
                    .subtract(priorYearPending.encumbrance);
            } else { // balance forwards have been run, don't need to consider prior year remaining budget
                availableBalance = sfBalance.getCurrentBudgetBalanceAmount()
                    .add(pending.actual)
                    .subtract(sfBalance.getAccountEncumbranceAmount());
            }
        } else {
            availableBalance = sfBalance.getCurrentBudgetBalanceAmount() // current budget balance
                .add(pending.budget) // pending budget entries
                .subtract(sfBalance.getAccountActualExpenditureAmt()) // minus all current and pending actuals and encumbrances
                .subtract(pending.actual)
                .subtract(sfBalance.getAccountEncumbranceAmount())
                .subtract(pending.encumbrance);
        }

        LOG.debug("hasSufficientFundsOnItem() balanceAmount: " + balanceAmount + " availableBalance: " + availableBalance);
        if (balanceAmount.compareTo(availableBalance) > 0) {
            LOG.debug("hasSufficientFundsOnItem() no sufficient funds");
            return false;
        }

        LOG.debug("hasSufficientFundsOnItem() has sufficient funds");
        return true;
    }

    /**
     * An inner class to hold summary totals of pending ledger entry amounts
     */
    protected class PendingAmounts {
        public KualiDecimal budget = KualiDecimal.ZERO;
        public KualiDecimal actual = KualiDecimal.ZERO;
        public KualiDecimal encumbrance = KualiDecimal.ZERO;
    }

    /**
     * Given a sufficient funds item to check, gets the prior year sufficient funds balance to check against
     *
     * @param item the sufficient funds item to check against
     * @return a PendingAmounts record with the pending budget and encumbrance
     */
    protected PendingAmounts getPriorYearSufficientFundsBalanceAmount(SufficientFundsItem item) {
        PendingAmounts amounts = new PendingAmounts();

        // This only gets called for sufficient funds type of Cash at Account (H). The object code in the table for this type is
        // always
        // 4 spaces.
        Map<String, Object> keys = new HashMap<String, Object>();
        keys.put(KFSPropertyConstants.UNIVERSITY_FISCAL_YEAR, Integer.valueOf(item.getYear().getUniversityFiscalYear().intValue() - 1));
        keys.put(KFSPropertyConstants.CHART_OF_ACCOUNTS_CODE, item.getAccount().getChartOfAccountsCode());
        keys.put(KFSPropertyConstants.ACCOUNT_NUMBER, item.getAccount().getAccountNumber());
        keys.put(KFSPropertyConstants.FINANCIAL_OBJECT_CODE, "    ");
        SufficientFundBalances bal = (SufficientFundBalances) businessObjectService.findByPrimaryKey(SufficientFundBalances.class, keys);

        if (bal != null) {
            amounts.budget = bal.getCurrentBudgetBalanceAmount();
            amounts.encumbrance = bal.getAccountEncumbranceAmount();
        }

        LOG.debug("getPriorYearSufficientFundsBalanceAmount() budget      " + amounts.budget);
        LOG.debug("getPriorYearSufficientFundsBalanceAmount() encumbrance " + amounts.encumbrance);
        return amounts;
    }

    /**
     * Totals the amounts of actual, encumbrance, and budget amounts from related pending entries
     *
     * @param item a sufficient funds item to find pending amounts for
     * @return the totals encapsulated in a PendingAmounts object
     */
    @SuppressWarnings("unchecked")
    protected PendingAmounts getPendingBalanceAmount(SufficientFundsItem item, List<String> transactionDocumentNumbers) {
        LOG.debug("getPendingBalanceAmount() started");

        Integer fiscalYear = item.getYear().getUniversityFiscalYear();
        String chart = item.getAccount().getChartOfAccountsCode();
        String account = item.getAccount().getAccountNumber();
        String sfCode = item.getAccount().getAccountSufficientFundsCode();

        PendingAmounts amounts = new PendingAmounts();

        if (KFSConstants.SF_TYPE_CASH_AT_ACCOUNT.equals(sfCode)) {
            // Cash checking
            List years = new ArrayList();
            years.add(item.getYear().getUniversityFiscalYear());

            // If the beginning balance isn't loaded, we need to include cash from
            // the previous fiscal year
            if (!item.getYear().isFinancialBeginBalanceLoadInd()) {
                years.add(item.getYear().getUniversityFiscalYear() - 1);
            }

            // Calculate the pending actual amount
            // Get Cash (debit amount - credit amount)
            amounts.actual = generalLedgerPendingEntryService.getCashSummary(years, chart, account, true);
            amounts.actual = amounts.actual.subtract(generalLedgerPendingEntryService.getCashSummary(years, chart, account, false));

            // Get Payables (credit amount - debit amount)
            amounts.actual = amounts.actual.add(generalLedgerPendingEntryService.getActualSummary(years, chart, account, true));
            amounts.actual = amounts.actual.subtract(generalLedgerPendingEntryService.getActualSummary(years, chart, account, false));
        } else {
            // Non-Cash checking

            // Get expenditure (debit - credit)
            amounts.actual = generalLedgerPendingEntryService.getExpenseSummary(fiscalYear, chart, account, item.getSufficientFundsObjectCode(), true, item.getDocumentTypeCode().startsWith("YE"), transactionDocumentNumbers);
            amounts.actual = amounts.actual.subtract(generalLedgerPendingEntryService.getExpenseSummary(fiscalYear, chart, account, item.getSufficientFundsObjectCode(), false, item.getDocumentTypeCode().startsWith("YE"), transactionDocumentNumbers));

            // Get budget
            amounts.budget = generalLedgerPendingEntryService.getBudgetSummary(fiscalYear, chart, account, item.getSufficientFundsObjectCode(), item.getDocumentTypeCode().startsWith("YE"));

            // Get encumbrance (debit - credit)
            amounts.encumbrance = generalLedgerPendingEntryService.getEncumbranceSummary(fiscalYear, chart, account, item.getSufficientFundsObjectCode(), true, item.getDocumentTypeCode().startsWith("YE"), transactionDocumentNumbers);
            amounts.encumbrance = amounts.encumbrance.subtract(generalLedgerPendingEntryService.getEncumbranceSummary(fiscalYear, chart, account, item.getSufficientFundsObjectCode(), false, item.getDocumentTypeCode().startsWith("YE"), transactionDocumentNumbers));
        }

        LOG.debug("getPendingBalanceAmount() actual      " + amounts.actual);
        LOG.debug("getPendingBalanceAmount() budget      " + amounts.budget);
        LOG.debug("getPendingBalanceAmount() encumbrance " + amounts.encumbrance);
        return amounts;
    }

    public void setAccountService(AccountService accountService) {
        this.accountService = accountService;
    }

    public void setGeneralLedgerPendingEntryService(GeneralLedgerPendingEntryService generalLedgerPendingEntryService) {
        this.generalLedgerPendingEntryService = generalLedgerPendingEntryService;
    }

    public void setConfigurationService(ConfigurationService kualiConfigurationService) {
        this.kualiConfigurationService = kualiConfigurationService;
    }

    public void setObjectLevelService(ObjectLevelService objectLevelService) {
        this.objectLevelService = objectLevelService;
    }

    public void setOptionsService(OptionsService optionsService) {
        this.optionsService = optionsService;
    }

    public void setSufficientFundBalancesDao(SufficientFundBalancesDao sufficientFundBalancesDao) {
        this.sufficientFundBalancesDao = sufficientFundBalancesDao;
    }

    public void setSufficientFundsDao(SufficientFundsDao sufficientFundsDao) {
        this.sufficientFundsDao = sufficientFundsDao;
    }

    public void setBusinessObjectService(BusinessObjectService businessObjectService) {
        this.businessObjectService = businessObjectService;
    }

    public void setObjectTypeService(ObjectTypeService objectTypeService) {
        this.objectTypeService = objectTypeService;
    }
}
