/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.camel.dataformat.bindy;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;

import org.apache.camel.CamelContext;
import org.apache.camel.dataformat.bindy.annotation.BindyConverter;
import org.apache.camel.dataformat.bindy.annotation.CsvRecord;
import org.apache.camel.dataformat.bindy.annotation.DataField;
import org.apache.camel.dataformat.bindy.annotation.Link;
import org.apache.camel.dataformat.bindy.annotation.OneToMany;
import org.apache.camel.dataformat.bindy.annotation.Section;
import org.apache.camel.dataformat.bindy.format.FormatException;
import org.apache.camel.dataformat.bindy.util.ConverterUtils;
import org.apache.camel.support.ObjectHelper;
import org.apache.camel.util.ReflectionHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * The BindyCsvFactory is the class who allows to : Generate a model associated to a CSV record, bind data from a record
 * to the POJOs, export data of POJOs to a CSV record and format data into String, Date, Double, ... according to the
 * format/pattern defined
 */
public class BindyCsvFactory extends BindyAbstractFactory implements BindyFactory {

    private static final Logger LOG = LoggerFactory.getLogger(BindyCsvFactory.class);
    private static final String DOUBLE_QUOTES_SYMBOL = "\"";

    boolean isOneToMany;

    private Map<Integer, DataField> dataFields = new LinkedHashMap<>();
    private Map<Integer, Field> annotatedFields = new LinkedHashMap<>();
    private Map<String, Integer> sections = new HashMap<>();

    private int numberOptionalFields;
    private int numberMandatoryFields;
    private int totalFields;
    private int maxpos;

    private String separator;
    private boolean skipFirstLine;
    private boolean skipField;
    private boolean generateHeaderColumnNames;
    private boolean messageOrdered;
    private String quote;
    private boolean quoting;
    private boolean autospanLine;
    private boolean allowEmptyStream;
    private boolean quotingEscaped;
    private boolean quotingOnlyWhenNeeded;
    private boolean endWithLineBreak;
    private boolean removeQuotes;
    private boolean trimLine;

    public BindyCsvFactory(Class<?> type) throws Exception {
        super(type);

        // initialize specific parameters of the csv model
        initCsvModel();
    }

    /**
     * method uses to initialize the model representing the classes who will bind the data. This process will scan for
     * classes according to the package name provided, check the annotated classes and fields and retrieve the separator
     * of the CSV record
     *
     * @throws Exception
     */
    public void initCsvModel() {

        // Find annotated Datafields declared in the Model classes
        initAnnotatedFields();

        // initialize CSV parameter(s)
        // separator and skip first line from @CSVrecord annotation
        initCsvRecordParameters();
    }

    @Override
    public void initAnnotatedFields() {

        maxpos = 0;
        for (Class<?> cl : models) {
            List<Field> linkFields = new ArrayList<>();

            if (LOG.isDebugEnabled()) {
                LOG.debug("Class retrieved: {}", cl.getName());
            }

            for (Field field : cl.getDeclaredFields()) {
                DataField dataField = field.getAnnotation(DataField.class);
                if (dataField != null) {
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Position defined in the class: {}, position: {}, Field: {}",
                                cl.getName(), dataField.pos(), dataField);
                    }

                    if (dataField.required()) {
                        ++numberMandatoryFields;
                    } else {
                        ++numberOptionalFields;
                    }

                    int pos = dataField.pos();
                    if (annotatedFields.containsKey(pos)) {
                        Field f = annotatedFields.get(pos);
                        LOG.warn("Potentially invalid model: existing @DataField '{}' replaced by '{}'", f.getName(),
                                field.getName());
                    }
                    dataFields.put(pos, dataField);
                    annotatedFields.put(pos, field);
                    maxpos = Math.max(maxpos, pos);
                }

                Link linkField = field.getAnnotation(Link.class);

                if (linkField != null) {
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Class linked: {}, Field: {}", cl.getName(), field);
                    }
                    linkFields.add(field);
                }
            }

            if (!linkFields.isEmpty()) {
                annotatedLinkFields.put(cl.getName(), linkFields);
            }

            totalFields = numberMandatoryFields + numberOptionalFields;

            if (LOG.isDebugEnabled()) {
                LOG.debug("Number of optional fields: {}", numberOptionalFields);
                LOG.debug("Number of mandatory fields: {}", numberMandatoryFields);
                LOG.debug("Total: {}", totalFields);
            }
        }

        if (annotatedFields.size() < maxpos) {
            LOG.debug("Potentially incomplete model: some csv fields may not be mapped to @DataField members");
        }
    }

    @Override
    public void bind(CamelContext camelContext, List<String> tokens, Map<String, Object> model, int line) throws Exception {

        int pos = 1;
        int counterMandatoryFields = 0;

        for (String data : tokens) {

            // Get DataField from model
            DataField dataField = dataFields.get(pos);

            // If a DataField can be skipped, it needs to check whether it is in dataFields keyset
            if (isSkipField()) {
                if (dataFields.keySet().contains(pos)) {
                    counterMandatoryFields
                            = setDataFieldValue(camelContext, model, line, pos, counterMandatoryFields, data, dataField);
                }
            } else {
                counterMandatoryFields
                        = setDataFieldValue(camelContext, model, line, pos, counterMandatoryFields, data, dataField);
            }

            ++pos;

        }

        LOG.debug("Counter mandatory fields: {}", counterMandatoryFields);

        if (counterMandatoryFields < numberMandatoryFields) {
            throw new IllegalArgumentException("Some mandatory fields are missing, line: " + line);
        }

        if (pos < totalFields) {
            setDefaultValuesForFields(model);
        }

    }

    private int setDataFieldValue(
            CamelContext camelContext, Map<String, Object> model, int line, int pos, int counterMandatoryFields, String data,
            DataField dataField)
            throws Exception {
        org.apache.camel.util.ObjectHelper.notNull(dataField,
                "No position " + pos + " defined for the field: " + data + ", line: " + line);

        if (dataField.trim()) {
            data = data.trim();
        }

        if (dataField.required()) {
            // Increment counter of mandatory fields
            ++counterMandatoryFields;

            // Check if content of the field is empty
            // This is not possible for mandatory fields
            if (data.isEmpty()) {
                throw new IllegalArgumentException(
                        "The mandatory field defined at the position " + pos + " is empty for the line: " + line);
            }
        }

        // Get Field to be setted
        Field field = annotatedFields.get(pos);
        field.setAccessible(true);

        if (LOG.isDebugEnabled()) {
            LOG.debug("Pos: {}, Data: {}, Field type: {}", pos, data, field.getType());
        }

        // Create format object to format the field
        FormattingOptions formattingOptions = ConverterUtils.convert(dataField,
                field.getType(),
                field.getAnnotation(BindyConverter.class),
                getLocale());
        Format<?> format = formatFactory.getFormat(formattingOptions);

        // field object to be set
        Object modelField = model.get(field.getDeclaringClass().getName());

        // format the data received
        Object value;

        if (!data.isEmpty()) {
            try {
                if (quoting && quote != null && (data.contains("\\" + quote) || data.contains(quote)) && quotingEscaped) {
                    value = format.parse(data.replaceAll("\\\\" + quote, "\\" + quote));
                } else if (quote != null && quote.equals(DOUBLE_QUOTES_SYMBOL)
                        && data.contains(DOUBLE_QUOTES_SYMBOL + DOUBLE_QUOTES_SYMBOL) && !quotingEscaped) {
                    // If double-quotes are used to enclose fields, the two double
                    // quotes character must be replaced with one according to RFC 4180 section 2.7
                    value = format.parse(data.replace(DOUBLE_QUOTES_SYMBOL + DOUBLE_QUOTES_SYMBOL, DOUBLE_QUOTES_SYMBOL));
                } else {
                    value = format.parse(data);
                }
            } catch (FormatException ie) {
                throw new IllegalArgumentException(ie.getMessage() + ", position: " + pos + ", line: " + line, ie);
            } catch (Exception e) {
                throw new IllegalArgumentException(
                        "Parsing error detected for field defined at the position: " + pos + ", line: " + line, e);
            }
        } else {
            if (!dataField.defaultValue().isEmpty()) {
                value = format.parse(dataField.defaultValue());
            } else {
                value = getDefaultValueForPrimitive(field.getType());
            }
        }

        if (value != null && !dataField.method().isEmpty()) {
            Class<?> clazz;
            if (dataField.method().contains(".")) {
                clazz = camelContext.getClassResolver()
                        .resolveMandatoryClass(dataField.method().substring(0, dataField.method().lastIndexOf('.')));
            } else {
                clazz = field.getType();
            }

            String methodName = dataField.method().substring(dataField.method().lastIndexOf('.') + 1,
                    dataField.method().length());

            Method m = ReflectionHelper.findMethod(clazz, methodName, field.getType());
            if (m != null) {
                // this method must be static and return type
                // must be the same as the datafield and
                // must receive only the datafield value
                // as the method argument
                value = ObjectHelper.invokeMethod(m, null, value);
            } else {
                // fallback to method without parameter, that is on the value itself
                m = ReflectionHelper.findMethod(clazz, methodName);
                value = ObjectHelper.invokeMethod(m, value);
            }
        }

        field.set(modelField, value);
        return counterMandatoryFields;
    }

    @Override
    public String unbind(CamelContext camelContext, Map<String, Object> model) throws Exception {

        Map<Integer, List<String>> results = new HashMap<>();

        // Check if separator exists
        org.apache.camel.util.ObjectHelper.notNull(this.separator,
                "The separator has not been instantiated or property not defined in the @CsvRecord annotation");

        String carriageReturn = ConverterUtils.getStringCarriageReturn(getCarriageReturn());
        char separator = ConverterUtils.getCharDelimiter(this.getSeparator());

        if (LOG.isDebugEnabled()) {
            LOG.debug("Separator converted: '0x{}', from: {}", Integer.toHexString(separator), this.getSeparator());
        }

        for (Class<?> clazz : models) {
            if (model.containsKey(clazz.getName())) {

                Object obj = model.get(clazz.getName());
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Model object: {}, class: {}", obj, obj.getClass().getName());
                }
                if (obj != null) {

                    // Generate Csv table
                    generateCsvPositionMap(clazz, obj, results);

                }
            }
        }

        // Transpose result
        List<List<String>> l = new ArrayList<>();
        if (isOneToMany) {
            l = product(results);
        } else {
            // Convert Map<Integer, List> into List<List>
            TreeMap<Integer, List<String>> sortValues = new TreeMap<>(results);
            List<String> temp = new ArrayList<>();

            for (Entry<Integer, List<String>> entry : sortValues.entrySet()) {
                // Get list of values
                List<String> val = entry.getValue();

                // For one to one relation
                // There is only one item in the list
                String value = val.get(0);

                // Add the value to the temp array
                if (value != null) {
                    temp.add(value);
                } else {
                    temp.add("");
                }
            }

            l.add(temp);
        }

        StringBuilder buffer = new StringBuilder(256);
        Iterator<List<String>> it = l.iterator();
        while (it.hasNext()) {
            List<String> tokens = it.next();
            Iterator<String> itx = tokens.iterator();

            while (itx.hasNext()) {
                String res = itx.next();
                if (res != null) {
                    // RFC 4180 section 2.6 - fields containing line breaks, double
                    // quotes, and commas should be enclosed in double-quotes
                    boolean needsQuotes = quoting && quote != null &&
                            (!quotingOnlyWhenNeeded || res.contains(carriageReturn) || res.indexOf(separator) != -1
                                    || res.contains(quote));

                    if (needsQuotes) {
                        buffer.append(quote);

                        // CAMEL-7519 - improvement escape the token itself by prepending escape char
                        if (quotingEscaped && (res.contains("\\" + quote) || res.contains(quote))) {
                            buffer.append(res.replaceAll("\\" + quote, "\\\\" + quote));
                        } else if (!quotingEscaped && quote.equals(DOUBLE_QUOTES_SYMBOL) && res.contains(quote)) {
                            // If double-quotes are used to enclose fields, then a double-quote
                            // appearing inside a field must be escaped by preceding it with another
                            // double quote according to RFC 4180 section 2.7
                            buffer.append(res.replace(DOUBLE_QUOTES_SYMBOL, DOUBLE_QUOTES_SYMBOL + DOUBLE_QUOTES_SYMBOL));
                        } else {
                            buffer.append(res);
                        }

                        buffer.append(quote);
                    } else {
                        buffer.append(res);
                    }
                }

                if (itx.hasNext()) {
                    buffer.append(separator);
                }
            }

            if (it.hasNext()) {
                buffer.append(ConverterUtils.getStringCarriageReturn(getCarriageReturn()));
            }
        }

        return buffer.toString();
    }

    private List<List<String>> product(Map<Integer, List<String>> values) {
        TreeMap<Integer, List<String>> sortValues = new TreeMap<>(values);

        List<List<String>> product = new ArrayList<>();

        int idx = 0;
        int idxSize;
        do {
            idxSize = 0;
            List<String> v = new ArrayList<>();

            for (int ii = 1; ii <= sortValues.lastKey(); ii++) {
                List<String> l = values.get(ii);
                if (l == null) {
                    v.add("");
                    ++idxSize;
                    continue;
                }

                if (l.size() >= idx + 1) {
                    v.add(l.get(idx));
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Value: {}, pos: {}, at: {}", l.get(idx), ii, idx);
                    }
                } else {
                    v.add(l.get(0));
                    ++idxSize;
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Value: {}, pos: {}, at index: {}", l.get(0), ii, 0);
                    }
                }
            }

            if (idxSize != sortValues.lastKey()) {
                product.add(v);
            }
            ++idx;

        } while (idxSize != sortValues.lastKey());

        return product;
    }

    /**
     * Generate a table containing the data formatted and sorted with their position/offset If the model is Ordered than
     * a key is created combining the annotation @Section and Position of the field If a relation @OneToMany is defined,
     * than we iterate recursively through this function The result is placed in the Map<Integer, List> results
     */
    private void generateCsvPositionMap(Class<?> clazz, Object obj, Map<Integer, List<String>> results) throws Exception {

        String result = "";

        for (Field field : clazz.getDeclaredFields()) {

            field.setAccessible(true);

            DataField datafield = field.getAnnotation(DataField.class);

            if (datafield != null) {

                if (obj != null) {

                    // Create format
                    FormattingOptions formattingOptions = ConverterUtils.convert(datafield,
                            field.getType(),
                            field.getAnnotation(BindyConverter.class),
                            getLocale());
                    Format<?> format = formatFactory.getFormat(formattingOptions);

                    // Get field value
                    Object value = field.get(obj);

                    // If the field value is empty, populate it with the default value
                    if (org.apache.camel.util.ObjectHelper.isNotEmpty(datafield.defaultValue())
                            && org.apache.camel.util.ObjectHelper.isEmpty(value)) {
                        value = datafield.defaultValue();
                    }

                    result = formatString(format, value);

                    if (datafield.trim()) {
                        result = result.trim();
                    }

                    if (datafield.clip() && result.length() > datafield.length()) {
                        result = result.substring(0, datafield.length());
                    }

                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Value to be formatted: {}, position: {}, and its formatted value: {}", value,
                                datafield.pos(), result);
                    }

                } else {
                    result = "";
                }

                Integer key;

                if (isMessageOrdered() && obj != null) {

                    // Generate a key using the number of the section
                    // and the position of the field
                    Integer key1 = sections.get(obj.getClass().getName());
                    Integer key2 = datafield.position();
                    Integer keyGenerated = generateKey(key1, key2);

                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Key generated: {}, for section: {}", String.valueOf(keyGenerated), key1);
                    }

                    key = keyGenerated;

                } else {
                    key = datafield.pos();
                }

                if (!results.containsKey(key)) {
                    List<String> list = new LinkedList<>();
                    list.add(result);
                    results.put(key, list);
                } else {
                    List<String> list = results.get(key);
                    list.add(result);
                }

            }

            OneToMany oneToMany = field.getAnnotation(OneToMany.class);
            if (oneToMany != null) {

                // Set global variable
                // Will be used during generation of CSV
                isOneToMany = true;

                List<?> list = (List<?>) field.get(obj);
                if (list != null) {

                    Iterator<?> it = list.iterator();
                    while (it.hasNext()) {
                        Object target = it.next();
                        generateCsvPositionMap(target.getClass(), target, results);
                    }

                } else {

                    // Call this function to add empty value
                    // in the table
                    generateCsvPositionMap(field.getClass(), null, results);
                }

            }
        }

    }

    /**
     * Generate for the first line the headers of the columns
     *
     * @return the headers columns
     */
    public String generateHeader() {

        Map<Integer, DataField> dataFieldsSorted = new TreeMap<>(dataFields);
        Iterator<Integer> it = dataFieldsSorted.keySet().iterator();

        StringBuilder builderHeader = new StringBuilder(256);

        while (it.hasNext()) {

            DataField dataField = dataFieldsSorted.get(it.next());

            // Retrieve the field
            Field field = annotatedFields.get(dataField.pos());
            // Change accessibility to allow to read protected/private fields
            field.setAccessible(true);

            // Get dataField
            final String res;
            if (!dataField.columnName().isEmpty()) {
                res = dataField.columnName();
            } else {
                res = field.getName();
            }

            if (quoting && quote != null) {
                builderHeader.append(quote);
            }
            if (quoting && quote != null && (res.contains("\\" + quote) || res.contains(quote)) && quotingEscaped) {
                builderHeader.append(res.replaceAll("\\" + quote, "\\\\" + quote));
            } else {
                builderHeader.append(res);
            }
            if (quoting && quote != null) {
                builderHeader.append(quote);
            }

            if (it.hasNext()) {
                builderHeader.append(ConverterUtils.getCharDelimiter(separator));
            }

        }

        return builderHeader.toString();
    }

    /**
     * Get parameters defined in @CsvRecord annotation
     */
    private void initCsvRecordParameters() {
        if (separator == null) {
            for (Class<?> cl : models) {

                // Get annotation @CsvRecord from the class
                CsvRecord csvRecord = cl.getAnnotation(CsvRecord.class);

                // Get annotation @Section from the class
                Section section = cl.getAnnotation(Section.class);

                if (csvRecord != null) {
                    LOG.debug("Csv record: {}", csvRecord);

                    // Get skipFirstLine parameter
                    skipFirstLine = csvRecord.skipFirstLine();
                    LOG.debug("Skip First Line parameter of the CSV: {}", skipFirstLine);

                    // Get skipFirstLine parameter
                    skipField = csvRecord.skipField();
                    LOG.debug("Skip Field parameter of the CSV: {}", skipField);

                    // Get generateHeaderColumnNames parameter
                    generateHeaderColumnNames = csvRecord.generateHeaderColumns();
                    LOG.debug("Generate header column names parameter of the CSV: {}", generateHeaderColumnNames);

                    // Get Separator parameter
                    org.apache.camel.util.ObjectHelper.notNull(csvRecord.separator(),
                            "No separator has been defined in the @Record annotation");
                    separator = csvRecord.separator();
                    LOG.debug("Separator defined for the CSV: {}", separator);

                    // Get carriage return parameter
                    crlf = csvRecord.crlf();
                    LOG.debug("Carriage return defined for the CSV: {}", crlf);

                    // Get isOrdered parameter
                    messageOrdered = csvRecord.isOrdered();
                    LOG.debug("Must CSV record be ordered: {}", messageOrdered);

                    if (org.apache.camel.util.ObjectHelper.isNotEmpty(csvRecord.quote())) {
                        quote = csvRecord.quote();
                        LOG.debug("Quoting columns with: {}", quote);
                    }

                    quoting = csvRecord.quoting();
                    LOG.debug("CSV will be quoted: {}", quoting);

                    autospanLine = csvRecord.autospanLine();
                    LOG.debug("Autospan line in last record: {}", autospanLine);

                    // Get allowEmptyStream parameter
                    allowEmptyStream = csvRecord.allowEmptyStream();
                    LOG.debug("Allow empty stream parameter of the CSV: {}", allowEmptyStream);

                    // Get quotingEscaped parameter
                    quotingEscaped = csvRecord.quotingEscaped();
                    LOG.debug("Escape quote character flag of the CSV: {}", quotingEscaped);

                    // Get quotingOnlyWhenNeeded parameter
                    quotingOnlyWhenNeeded = csvRecord.quotingOnlyWhenNeeded();
                    LOG.debug("Quoting only when needed: {}", quotingOnlyWhenNeeded);

                    // Get endWithLineBreak parameter
                    endWithLineBreak = csvRecord.endWithLineBreak();
                    LOG.debug("End with line break: {}", endWithLineBreak);

                    removeQuotes = csvRecord.removeQuotes();
                    LOG.debug("Remove quotes: {}", removeQuotes);

                    trimLine = csvRecord.trimLine();
                    LOG.debug("Trim line: {}", trimLine);
                }

                if (section != null) {
                    // BigIntegerFormatFactory if section number is not null
                    org.apache.camel.util.ObjectHelper.notNull(section.number(), "No number has been defined for the section");

                    // Get section number and add it to the sections
                    sections.put(cl.getName(), section.number());
                }
            }
        }
    }

    /**
     * Set the default values for the non defined fields.
     *
     * @param  model                  the model which has its default fields set.
     * @throws IllegalAccessException if the underlying fields are inaccessible
     * @throws Exception              In case the field cannot be parsed
     */
    private void setDefaultValuesForFields(final Map<String, Object> model)
            throws Exception {
        // Set the default values, if defined
        for (int i = 1; i <= dataFields.size(); i++) {
            Field field = annotatedFields.get(i);
            field.setAccessible(true);
            DataField dataField = dataFields.get(i);
            Object modelField = model.get(field.getDeclaringClass().getName());
            if (field.get(modelField) == null && !dataField.defaultValue().isEmpty()) {
                FormattingOptions formattingOptions = ConverterUtils.convert(dataField,
                        field.getType(),
                        field.getAnnotation(BindyConverter.class),
                        getLocale());
                Format<?> format = formatFactory.getFormat(formattingOptions);
                Object value = format.parse(dataField.defaultValue());
                field.set(modelField, value);
            }
        }
    }

    /**
     * Find the separator used to delimit the CSV fields
     */
    public String getSeparator() {
        return separator;
    }

    /**
     * Flag indicating if the first line of the CSV must be skipped
     */
    public boolean getGenerateHeaderColumnNames() {
        return generateHeaderColumnNames;
    }

    /**
     * Find the separator used to delimit the CSV fields
     */
    public boolean getSkipFirstLine() {
        return skipFirstLine;
    }

    /**
     * Indicate if can skip fields
     *
     * @return boolean
     */
    public boolean isSkipField() {
        return this.skipField;
    }

    /**
     * If last record is to span the rest of the line
     */
    public boolean getAutospanLine() {
        return autospanLine;
    }

    /**
     * Flag indicating if the message must be ordered
     *
     * @return boolean
     */
    public boolean isMessageOrdered() {
        return messageOrdered;
    }

    public String getQuote() {
        return quote;
    }

    public Boolean getRemoveQuotes() {
        return removeQuotes;
    }

    public int getMaxpos() {
        return maxpos;
    }

    public boolean isAllowEmptyStream() {
        return allowEmptyStream;
    }

    public boolean isEndWithLineBreak() {
        return endWithLineBreak;
    }

    public boolean isTrimLine() {
        return trimLine;
    }
}
