/*
 * 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.sys.businessobject;

import org.apache.commons.lang.StringUtils;
import org.kuali.kfs.coa.service.AccountService;
import org.kuali.kfs.kns.service.BusinessObjectDictionaryService;
import org.kuali.kfs.kns.service.DataDictionaryService;
import org.kuali.kfs.krad.exception.InfrastructureException;
import org.kuali.kfs.krad.util.GlobalVariables;
import org.kuali.kfs.krad.util.ObjectUtils;
import org.kuali.kfs.sys.KFSConstants;
import org.kuali.kfs.sys.KFSKeyConstants;
import org.kuali.kfs.sys.KFSPropertyConstants;
import org.kuali.kfs.sys.context.SpringContext;
import org.kuali.kfs.sys.document.AccountingDocument;
import org.kuali.kfs.sys.exception.AccountingLineParserException;
import org.kuali.rice.core.web.format.FormatException;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import static org.kuali.kfs.sys.KFSKeyConstants.AccountingLineParser.ERROR_INVALID_FILE_FORMAT;
import static org.kuali.kfs.sys.KFSKeyConstants.AccountingLineParser.ERROR_INVALID_PROPERTY_VALUE;
import static org.kuali.kfs.sys.KFSPropertyConstants.ACCOUNT_NUMBER;
import static org.kuali.kfs.sys.KFSPropertyConstants.AMOUNT;
import static org.kuali.kfs.sys.KFSPropertyConstants.CHART_OF_ACCOUNTS_CODE;
import static org.kuali.kfs.sys.KFSPropertyConstants.FINANCIAL_OBJECT_CODE;
import static org.kuali.kfs.sys.KFSPropertyConstants.FINANCIAL_SUB_OBJECT_CODE;
import static org.kuali.kfs.sys.KFSPropertyConstants.ORGANIZATION_REFERENCE_ID;
import static org.kuali.kfs.sys.KFSPropertyConstants.OVERRIDE_CODE;
import static org.kuali.kfs.sys.KFSPropertyConstants.POSTING_YEAR;
import static org.kuali.kfs.sys.KFSPropertyConstants.PROJECT_CODE;
import static org.kuali.kfs.sys.KFSPropertyConstants.SEQUENCE_NUMBER;
import static org.kuali.kfs.sys.KFSPropertyConstants.SUB_ACCOUNT_NUMBER;

/**
 * Base class for parsing serialized <code>AccountingLine</code>s for <code>TransactionalDocument</code>s
 */
public class AccountingLineParserBase implements AccountingLineParser {
    protected static final String[] DEFAULT_FORMAT = {CHART_OF_ACCOUNTS_CODE, ACCOUNT_NUMBER, SUB_ACCOUNT_NUMBER, FINANCIAL_OBJECT_CODE, FINANCIAL_SUB_OBJECT_CODE, PROJECT_CODE, ORGANIZATION_REFERENCE_ID, AMOUNT};
    private String fileName;
    private Integer lineNo = 0;

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#getSourceAccountingLineFormat()
     */
    public String[] getSourceAccountingLineFormat() {
        return removeChartFromFormatIfNeeded(DEFAULT_FORMAT);
    }

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#getTargetAccountingLineFormat()
     */
    public String[] getTargetAccountingLineFormat() {
        return removeChartFromFormatIfNeeded(DEFAULT_FORMAT);
    }

    /**
     * If accounts can cross charts, returns the given format;
     * otherwise returns the format with ChartOfAccountsCode field removed.
     */
    public String[] removeChartFromFormatIfNeeded(String[] format) {
        if (SpringContext.getBean(AccountService.class).accountsCanCrossCharts()) {
            return format;
        }

        // if accounts can't cross charts, exclude ChartOfAccountsCode field from the format
        String[] formatNoChart = new String[format.length - 1];
        int idx = 0;
        for (int i = 0; i < format.length; i++) {
            if (format[i].equals(CHART_OF_ACCOUNTS_CODE))
                continue;
            else {
                formatNoChart[idx] = format[i];
                idx++;
            }
        }
        return formatNoChart;
    }

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#getExpectedAccountingLineFormatAsString(java.lang.Class)
     */
    public String getExpectedAccountingLineFormatAsString(Class<? extends AccountingLine> accountingLineClass) {
        StringBuffer sb = new StringBuffer();
        boolean first = true;
        for (String attributeName : chooseFormat(accountingLineClass)) {
            if (!first) {
                sb.append(",");
            } else {
                first = false;
            }
            sb.append(retrieveAttributeLabel(accountingLineClass, attributeName));
        }
        return sb.toString();
    }

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#parseSourceAccountingLine(org.kuali.rice.krad.document.TransactionalDocument,
     * java.lang.String)
     */
    public SourceAccountingLine parseSourceAccountingLine(AccountingDocument transactionalDocument, String sourceAccountingLineString) {
        Class sourceAccountingLineClass = getSourceAccountingLineClass(transactionalDocument);
        SourceAccountingLine sourceAccountingLine = (SourceAccountingLine) populateAccountingLine(transactionalDocument, sourceAccountingLineClass, sourceAccountingLineString, parseAccountingLine(sourceAccountingLineClass, sourceAccountingLineString), transactionalDocument.getNextSourceLineNumber());
        return sourceAccountingLine;
    }

    /**
     * Given a document, determines what class the source lines of that document uses
     *
     * @param accountingDocument the document to find the class of the source lines for
     * @return the class of the source lines
     */
    protected Class getSourceAccountingLineClass(final AccountingDocument accountingDocument) {
        return accountingDocument.getSourceAccountingLineClass();
    }

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#parseTargetAccountingLine(org.kuali.rice.krad.document.TransactionalDocument,
     * java.lang.String)
     */
    public TargetAccountingLine parseTargetAccountingLine(AccountingDocument transactionalDocument, String targetAccountingLineString) {
        Class targetAccountingLineClass = getTargetAccountingLineClass(transactionalDocument);
        TargetAccountingLine targetAccountingLine = (TargetAccountingLine) populateAccountingLine(transactionalDocument, targetAccountingLineClass, targetAccountingLineString, parseAccountingLine(targetAccountingLineClass, targetAccountingLineString), transactionalDocument.getNextTargetLineNumber());
        return targetAccountingLine;
    }

    /**
     * Given a document, determines what class that document uses for target accounting lines
     *
     * @param accountingDocument the document to determine the target accounting line class for
     * @return the class of the target lines for the given document
     */
    protected Class getTargetAccountingLineClass(final AccountingDocument accountingDocument) {
        return accountingDocument.getTargetAccountingLineClass();
    }

    /**
     * Populates a source/target line with values
     *
     * @param transactionalDocument
     * @param accountingLineClass
     * @param accountingLineAsString
     * @param attributeValueMap
     * @param sequenceNumber
     * @return AccountingLine
     */
    protected AccountingLine populateAccountingLine(AccountingDocument transactionalDocument, Class<? extends AccountingLine> accountingLineClass, String accountingLineAsString, Map<String, String> attributeValueMap, Integer sequenceNumber) {

        putCommonAttributesInMap(attributeValueMap, transactionalDocument, sequenceNumber);

        // create line and populate fields
        AccountingLine accountingLine;

        try {
            accountingLine = (AccountingLine) accountingLineClass.newInstance();

            // perform custom line population
            if (SourceAccountingLine.class.isAssignableFrom(accountingLineClass)) {
                performCustomSourceAccountingLinePopulation(attributeValueMap, (SourceAccountingLine) accountingLine, accountingLineAsString);
            } else if (TargetAccountingLine.class.isAssignableFrom(accountingLineClass)) {
                performCustomTargetAccountingLinePopulation(attributeValueMap, (TargetAccountingLine) accountingLine, accountingLineAsString);
            } else {
                throw new IllegalArgumentException("invalid (unknown) accounting line type: " + accountingLineClass);
            }

            for (Entry<String, String> entry : attributeValueMap.entrySet()) {
                try {
                    try {
                        Class entryType = ObjectUtils.easyGetPropertyType(accountingLine, entry.getKey());
                        if (String.class.isAssignableFrom(entryType)) {
                            entry.setValue(entry.getValue().toUpperCase());
                        }
                        ObjectUtils.setObjectProperty(accountingLine, entry.getKey(), entryType, entry.getValue());
                    } catch (IllegalArgumentException e) {
                        throw new InfrastructureException("unable to complete accounting line population.", e);
                    }
                } catch (FormatException e) {
                    String[] errorParameters = {entry.getValue().toString(), retrieveAttributeLabel(accountingLine.getClass(), entry.getKey()), accountingLineAsString};
                    // KULLAB-408
                    GlobalVariables.getMessageMap().putError(KFSConstants.ACCOUNTING_LINE_ERRORS, ERROR_INVALID_PROPERTY_VALUE, entry.getValue().toString(), entry.getKey(), accountingLineAsString + "  : Line Number " + lineNo.toString());
                    throw new AccountingLineParserException("invalid '" + entry.getKey() + "=" + entry.getValue() + "for " + accountingLineAsString, ERROR_INVALID_PROPERTY_VALUE, errorParameters);
                }
            }

            // override chart code if accounts can't cross charts
            SpringContext.getBean(AccountService.class).populateAccountingLineChartIfNeeded(accountingLine);
        } catch (SecurityException e) {
            throw new InfrastructureException("unable to complete accounting line population.", e);
        } catch (NoSuchMethodException e) {
            throw new InfrastructureException("unable to complete accounting line population.", e);
        } catch (IllegalAccessException e) {
            throw new InfrastructureException("unable to complete accounting line population.", e);
        } catch (InvocationTargetException e) {
            throw new InfrastructureException("unable to complete accounting line population.", e);
        } catch (InstantiationException e) {
            throw new InfrastructureException("unable to complete accounting line population.", e);
        }

        // force input to uppercase
        SpringContext.getBean(BusinessObjectDictionaryService.class).performForceUppercase(accountingLine);
        accountingLine.refresh();

        return accountingLine;
    }

    /**
     * Places fields common to both source/target accounting lines in the attribute map
     *
     * @param attributeValueMap
     * @param document
     * @param sequenceNumber
     */
    protected void putCommonAttributesInMap(Map<String, String> attributeValueMap, AccountingDocument document, Integer sequenceNumber) {
        attributeValueMap.put(KFSPropertyConstants.DOCUMENT_NUMBER, document.getDocumentNumber());
        attributeValueMap.put(POSTING_YEAR, document.getPostingYear().toString());
        attributeValueMap.put(SEQUENCE_NUMBER, sequenceNumber.toString());
    }

    /**
     * Parses the csv line
     *
     * @param accountingLineClass
     * @param lineToParse
     * @return Map containing accounting line attribute,value pairs
     */
    protected Map<String, String> parseAccountingLine(Class<? extends AccountingLine> accountingLineClass, String lineToParse) {
        if (StringUtils.isNotBlank(fileName) && !StringUtils.lowerCase(fileName).endsWith(".csv")) {
            throw new AccountingLineParserException("unsupported file format: " + fileName, ERROR_INVALID_FILE_FORMAT, fileName);
        }
        String[] attributes = chooseFormat(accountingLineClass);
        String[] attributeValues = StringUtils.splitPreserveAllTokens(lineToParse, ",");

        Map<String, String> attributeValueMap = new HashMap<String, String>();

        for (int i = 0; i < Math.min(attributeValues.length, attributes.length); i++) {
            attributeValueMap.put(attributes[i], attributeValues[i]);
        }

        return attributeValueMap;
    }

    /**
     * Should be voerriden by documents to perform any additional <code>SourceAccountingLine</code> population
     *
     * @param attributeValueMap
     * @param sourceAccountingLine
     * @param accountingLineAsString
     */
    protected void performCustomSourceAccountingLinePopulation(Map<String, String> attributeValueMap, SourceAccountingLine sourceAccountingLine, String accountingLineAsString) {
    }

    /**
     * Should be overridden by documents to perform any additional <code>TargetAccountingLine</code> attribute population
     *
     * @param attributeValueMap
     * @param targetAccountingLine
     * @param accountingLineAsString
     */
    protected void performCustomTargetAccountingLinePopulation(Map<String, String> attributeValueMap, TargetAccountingLine targetAccountingLine, String accountingLineAsString) {
    }

    /**
     * Calls the appropriate parseAccountingLine method
     *
     * @param stream
     * @param transactionalDocument
     * @param isSource
     * @return List
     */
    protected List<AccountingLine> importAccountingLines(String fileName, InputStream stream, AccountingDocument transactionalDocument, boolean isSource) {
        List<AccountingLine> importedAccountingLines = new ArrayList<AccountingLine>();
        this.fileName = fileName;
        BufferedReader br = new BufferedReader(new InputStreamReader(stream));

        try {
            String accountingLineAsString = null;
            lineNo = 0;
            while ((accountingLineAsString = br.readLine()) != null) {
                lineNo++;

                if (StringUtils.isBlank(StringUtils.remove(StringUtils.deleteWhitespace(accountingLineAsString), KFSConstants.COMMA))) {
                    continue;
                }

                AccountingLine accountingLine = null;

                try {
                    if (isSource) {
                        accountingLine = parseSourceAccountingLine(transactionalDocument, accountingLineAsString);
                    } else {
                        accountingLine = parseTargetAccountingLine(transactionalDocument, accountingLineAsString);
                    }

                    validateImportedAccountingLine(accountingLine, accountingLineAsString);
                    importedAccountingLines.add(accountingLine);
                } catch (AccountingLineParserException e) {
                    GlobalVariables.getMessageMap().putError((isSource ? "sourceAccountingLines" : "targetAccountingLines"), KFSKeyConstants.ERROR_ACCOUNTING_DOCUMENT_ACCOUNTING_LINE_IMPORT_GENERAL, new String[]{e.getMessage()});
                }
            }
        } catch (IOException e) {
            throw new InfrastructureException("unable to readLine from bufferReader in accountingLineParserBase", e);
        } finally {
            try {
                br.close();
            } catch (IOException e) {
                throw new InfrastructureException("unable to close bufferReader in accountingLineParserBase", e);
            }
        }

        return importedAccountingLines;
    }

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#importSourceAccountingLines(java.io.InputStream,
     * org.kuali.rice.krad.document.TransactionalDocument)
     */
    public final List importSourceAccountingLines(String fileName, InputStream stream, AccountingDocument document) {
        return importAccountingLines(fileName, stream, document, true);
    }

    /**
     * @see org.kuali.rice.krad.bo.AccountingLineParser#importTargetAccountingLines(java.io.InputStream,
     * org.kuali.rice.krad.document.TransactionalDocument)
     */
    public final List importTargetAccountingLines(String fileName, InputStream stream, AccountingDocument document) {
        return importAccountingLines(fileName, stream, document, false);
    }

    /**
     * performs any additional accounting line validation
     *
     * @param line
     * @param accountingLineAsString
     * @throws AccountingLineParserException
     */
    protected void validateImportedAccountingLine(AccountingLine line, String accountingLineAsString) throws AccountingLineParserException {
        // This check isn't done for the web UI because the code is never input from the user and doesn't correspond to a displayed
        // property that could be error highlighted. Throwing an exception here follows the convention of TooFewFieldsException
        // and the unchecked NumberFormatException, altho todo: reconsider design, e.g., KULFDBCK-478
        String overrideCode = line.getOverrideCode();
        if (!AccountingLineOverride.isValidCode(overrideCode)) {
            String[] errorParameters = {overrideCode, retrieveAttributeLabel(line.getClass(), OVERRIDE_CODE), accountingLineAsString};
            throw new AccountingLineParserException("invalid overrride code '" + overrideCode + "' for:" + accountingLineAsString, ERROR_INVALID_PROPERTY_VALUE, errorParameters);
        }
    }

    protected String retrieveAttributeLabel(Class clazz, String attributeName) {
        String label = SpringContext.getBean(DataDictionaryService.class).getAttributeLabel(clazz, attributeName);
        if (StringUtils.isBlank(label)) {
            label = attributeName;
        }
        return label;
    }

    protected String[] chooseFormat(Class<? extends AccountingLine> accountingLineClass) {
        String[] format = null;
        if (SourceAccountingLine.class.isAssignableFrom(accountingLineClass)) {
            format = getSourceAccountingLineFormat();
        } else if (TargetAccountingLine.class.isAssignableFrom(accountingLineClass)) {
            format = getTargetAccountingLineFormat();
        } else {
            throw new IllegalStateException("unknow accounting line class: " + accountingLineClass);
        }
        return format;
    }
}
