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

import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;

import com.univocity.parsers.common.AbstractParser;
import com.univocity.parsers.common.AbstractWriter;
import com.univocity.parsers.common.CommonParserSettings;
import com.univocity.parsers.common.CommonSettings;
import com.univocity.parsers.common.CommonWriterSettings;
import com.univocity.parsers.common.Format;
import org.apache.camel.Exchange;
import org.apache.camel.spi.DataFormat;

import static org.apache.camel.util.IOHelper.getCharsetName;

/**
 * This abstract class contains all the common parts for all the uniVocity parsers.
 * <p/>
 * All setters methods <b>must</b> call the {@link #reset()} method in order to clear the cache for marshalling and
 * unmarshalling.
 *
 * @param <F>   uniVocity format class
 * @param <CWS> uniVocity writer settings class
 * @param <W>   uniVocity writer class
 * @param <CPS> uniVocity parser settings class
 * @param <P>   uniVocity parser class
 * @param <DF>  the data format class (for providing a fluent API)
 */
public abstract class AbstractUniVocityDataFormat<F extends Format, CWS extends CommonWriterSettings<F>,
        W extends AbstractWriter<CWS>, CPS extends CommonParserSettings<F>, P extends AbstractParser<CPS>, DF extends AbstractUniVocityDataFormat<F, CWS, W, CPS, P, DF>> implements DataFormat {
    protected String nullValue;
    protected Boolean skipEmptyLines;
    protected Boolean ignoreTrailingWhitespaces;
    protected Boolean ignoreLeadingWhitespaces;
    protected boolean headersDisabled;
    protected String[] headers;
    protected Boolean headerExtractionEnabled;
    protected Integer numberOfRecordsToRead;
    protected String emptyValue;
    protected String lineSeparator;
    protected Character normalizedLineSeparator;
    protected Character comment;
    protected boolean lazyLoad;
    protected boolean asMap;

    private volatile CWS writerSettings;
    private final Object writerSettingsToken = new Object();
    private volatile Marshaller<W> marshaller;

    // We're using a ThreadLocal for the parser settings because in order to retrieve the headers we need to change the
    // settings each time we're parsing...
    private volatile ThreadLocal<CPS> parserSettings;
    private final Object parserSettingsToken = new Object();
    private volatile Unmarshaller<P> unmarshaller;

    /**
     * {@inheritDoc}
     */
    @Override
    public void marshal(Exchange exchange, Object body, OutputStream stream) throws Exception {
        if (writerSettings == null) {
            synchronized (writerSettingsToken) {
                if (writerSettings == null) {
                    writerSettings = createAndConfigureWriterSettings();
                    marshaller = new Marshaller<W>(headers, headers == null);
                }
            }
        }

        Writer writer = new OutputStreamWriter(stream, getCharsetName(exchange));
        try {
            marshaller.marshal(exchange, body, createWriter(writer, writerSettings));
        } finally {
            writer.close();
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Object unmarshal(Exchange exchange, InputStream stream) throws Exception {
        if (parserSettings == null) {
            synchronized (parserSettingsToken) {
                if (parserSettings == null) {
                    parserSettings = new ThreadLocal<CPS>() {
                        @Override
                        protected CPS initialValue() {
                            return createAndConfigureParserSettings();
                        }
                    };
                    unmarshaller = new Unmarshaller<P>(lazyLoad, asMap);
                }
            }
        }

        Reader reader = new InputStreamReader(stream, getCharsetName(exchange));
        try {
            HeaderRowProcessor headerRowProcessor = new HeaderRowProcessor();
            CPS settings = parserSettings.get();
            settings.setRowProcessor(headerRowProcessor);
            return unmarshaller.unmarshal(reader, createParser(settings), headerRowProcessor);
        } finally {
            reader.close();
        }
    }

    /**
     * Gets the String representation of a null value.
     * If {@code null} then the default settings value is used.
     *
     * @return the String representation of a null value
     * @see com.univocity.parsers.common.CommonSettings#getNullValue()
     */
    public String getNullValue() {
        return nullValue;
    }

    /**
     * Sets the String representation of a null value.
     * If {@code null} then the default settings value is used.
     *
     * @param nullValue the String representation of a null value
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonSettings#setNullValue(String)
     */
    public DF setNullValue(String nullValue) {
        this.nullValue = nullValue;
        reset();
        return self();
    }

    /**
     * Gets whether or not empty lines should be ignored.
     * If {@code null} then the default settings value is used.
     *
     * @return whether or not empty lines should be ignored
     * @see com.univocity.parsers.common.CommonSettings#getSkipEmptyLines()
     */
    public Boolean getSkipEmptyLines() {
        return skipEmptyLines;
    }

    /**
     * Sets whether or not empty lines should be ignored.
     * If {@code null} then the default settings value is used.
     *
     * @param skipEmptyLines whether or not empty lines should be ignored
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonSettings#setSkipEmptyLines(boolean)
     */
    public DF setSkipEmptyLines(Boolean skipEmptyLines) {
        this.skipEmptyLines = skipEmptyLines;
        reset();
        return self();
    }

    /**
     * Gets whether or not trailing whitespaces should be ignored.
     * If {@code null} then the default settings value is used.
     *
     * @return whether or not trailing whitespaces should be ignored
     * @see com.univocity.parsers.common.CommonSettings#getIgnoreTrailingWhitespaces()
     */
    public Boolean getIgnoreTrailingWhitespaces() {
        return ignoreTrailingWhitespaces;
    }

    /**
     * Sets whether or not trailing whitespaces should be ignored.
     * If {@code null} then the default settings value is used.
     *
     * @param ignoreTrailingWhitespaces whether or not trailing whitespaces should be ignored
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonSettings#setIgnoreTrailingWhitespaces(boolean)
     */
    public DF setIgnoreTrailingWhitespaces(Boolean ignoreTrailingWhitespaces) {
        this.ignoreTrailingWhitespaces = ignoreTrailingWhitespaces;
        reset();
        return self();
    }

    /**
     * Gets whether or not leading whitespaces should be ignored.
     * If {@code null} then the default settings value is used.
     *
     * @return whether or not leading whitespaces should be ignored
     * @see com.univocity.parsers.common.CommonSettings#getIgnoreLeadingWhitespaces()
     */
    public Boolean getIgnoreLeadingWhitespaces() {
        return ignoreLeadingWhitespaces;
    }

    /**
     * Sets whether or not leading whitespaces should be ignored.
     * If {@code null} then the default settings value is used.
     *
     * @param ignoreLeadingWhitespaces whether or not leading whitespaces should be ignored
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonSettings#setIgnoreLeadingWhitespaces(boolean)
     */
    public DF setIgnoreLeadingWhitespaces(Boolean ignoreLeadingWhitespaces) {
        this.ignoreLeadingWhitespaces = ignoreLeadingWhitespaces;
        reset();
        return self();
    }

    /**
     * Gets whether or not headers are disabled.
     * If {@code true} then it passes {@code null} to
     * {@link com.univocity.parsers.common.CommonSettings#setHeaders(String...)} in order to disabled them.
     *
     * @return whether or not headers are disabled
     * @see com.univocity.parsers.common.CommonSettings#getHeaders()
     */
    public boolean isHeadersDisabled() {
        return headersDisabled;
    }

    /**
     * Sets whether or not headers are disabled.
     * If {@code true} then it passes {@code null} to
     * {@link com.univocity.parsers.common.CommonSettings#setHeaders(String...)} in order to disabled them.
     *
     * @param headersDisabled whether or not headers are disabled
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonSettings#setHeaders(String...)
     */
    public DF setHeadersDisabled(boolean headersDisabled) {
        this.headersDisabled = headersDisabled;
        reset();
        return self();
    }

    /**
     * Gets the headers.
     * If {@code null} then the default settings value is used.
     *
     * @return the headers
     * @see com.univocity.parsers.common.CommonSettings#getHeaders()
     */
    public String[] getHeaders() {
        return headers;
    }

    /**
     * Sets the headers.
     * If {@code null} then the default settings value is used.
     *
     * @param headers the headers
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonSettings#setHeaders(String...)
     */
    public DF setHeaders(String[] headers) {
        this.headers = headers;
        reset();
        return self();
    }

    /**
     * Gets whether or not the header extraction is enabled.
     * If {@code null} then the default settings value is used.
     *
     * @return whether or not the header extraction is enabled
     * @see com.univocity.parsers.common.CommonParserSettings#isHeaderExtractionEnabled()
     */
    public Boolean getHeaderExtractionEnabled() {
        return headerExtractionEnabled;
    }

    /**
     * Sets whether or not the header extraction is enabled.
     * If {@code null} then the default settings value is used.
     *
     * @param headerExtractionEnabled whether or not the header extraction is enabled
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonParserSettings#setHeaderExtractionEnabled(boolean)
     */
    public DF setHeaderExtractionEnabled(Boolean headerExtractionEnabled) {
        this.headerExtractionEnabled = headerExtractionEnabled;
        reset();
        return self();
    }

    /**
     * Gets the number of records to read.
     * If {@code null} then the default settings value is used.
     *
     * @return the number of records to read
     * @see com.univocity.parsers.common.CommonParserSettings#getNumberOfRecordsToRead()
     */
    public Integer getNumberOfRecordsToRead() {
        return numberOfRecordsToRead;
    }

    /**
     * Sets the number of records to read.
     * If {@code null} then the default settings value is used.
     *
     * @param numberOfRecordsToRead the number of records to read
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonParserSettings#setNumberOfRecordsToRead(int)
     */
    public DF setNumberOfRecordsToRead(Integer numberOfRecordsToRead) {
        this.numberOfRecordsToRead = numberOfRecordsToRead;
        reset();
        return self();
    }

    /**
     * Gets the String representation of an empty value.
     * If {@code null} then the default settings value is used.
     *
     * @return the String representation of an empty value
     * @see com.univocity.parsers.common.CommonWriterSettings#getEmptyValue()
     */
    public String getEmptyValue() {
        return emptyValue;
    }

    /**
     * Sets the String representation of an empty value.
     * If {@code null} then the default settings value is used.
     *
     * @param emptyValue the String representation of an empty value
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.CommonWriterSettings#setEmptyValue(String)
     */
    public DF setEmptyValue(String emptyValue) {
        this.emptyValue = emptyValue;
        reset();
        return self();
    }

    /**
     * Gets the line separator.
     * If {@code null} then the default format value is used.
     *
     * @return the line separator
     * @see com.univocity.parsers.common.Format#getLineSeparatorString()
     */
    public String getLineSeparator() {
        return lineSeparator;
    }

    /**
     * Sets the line separator.
     * If {@code null} then the default format value is used.
     *
     * @param lineSeparator the line separator
     * @return current data format instance, fluent API
     * @see Format#setLineSeparator(String)
     */
    public DF setLineSeparator(String lineSeparator) {
        this.lineSeparator = lineSeparator;
        reset();
        return self();
    }

    /**
     * Gets the normalized line separator.
     * If {@code null} then the default format value is used.
     *
     * @return the normalized line separator
     * @see com.univocity.parsers.common.Format#getNormalizedNewline()
     */
    public Character getNormalizedLineSeparator() {
        return normalizedLineSeparator;
    }

    /**
     * Sets the normalized line separator.
     * If {@code null} then the default format value is used.
     *
     * @param normalizedLineSeparator the normalized line separator
     * @return current data format instance, fluent API
     * @see Format#setNormalizedNewline(char)
     */
    public DF setNormalizedLineSeparator(Character normalizedLineSeparator) {
        this.normalizedLineSeparator = normalizedLineSeparator;
        reset();
        return self();
    }

    /**
     * Gets the comment symbol.
     * If {@code null} then the default format value is used.
     *
     * @return the comment symbol
     * @see com.univocity.parsers.common.Format#getComment()
     */
    public Character getComment() {
        return comment;
    }

    /**
     * Gets the comment symbol.
     * If {@code null} then the default format value is used.
     *
     * @param comment the comment symbol
     * @return current data format instance, fluent API
     * @see com.univocity.parsers.common.Format#setComment(char)
     */
    public DF setComment(Character comment) {
        this.comment = comment;
        reset();
        return self();
    }

    /**
     * Gets whether or not the unmarshalling should read lines lazily.
     *
     * @return whether or not the unmarshalling should read lines lazily
     */
    public boolean isLazyLoad() {
        return lazyLoad;
    }

    /**
     * Sets whether or not the unmarshalling should read lines lazily.
     *
     * @param lazyLoad whether or not the unmarshalling should read lines lazily
     * @return current data format instance, fluent API
     */
    public DF setLazyLoad(boolean lazyLoad) {
        this.lazyLoad = lazyLoad;
        reset();
        return self();
    }

    /**
     * Gets whether or not the unmarshalling should produces maps instead of lists.
     *
     * @return whether or not the unmarshalling should produces maps instead of lists
     */
    public boolean isAsMap() {
        return asMap;
    }

    /**
     * Sets whether or not the unmarshalling should produces maps instead of lists.
     *
     * @param asMap whether or not the unmarshalling should produces maps instead of lists
     * @return current data format instance, fluent API
     */
    public DF setAsMap(boolean asMap) {
        this.asMap = asMap;
        reset();
        return self();
    }

    /**
     * Creates a new instance of the writer settings.
     *
     * @return New instance of the writer settings
     */
    protected abstract CWS createWriterSettings();

    /**
     * Configures the writer settings.
     *
     * @param settings Writer settings to configure
     */
    protected void configureWriterSettings(CWS settings) {
        configureCommonSettings(settings);

        if (emptyValue != null) {
            settings.setEmptyValue(emptyValue);
        }
    }

    /**
     * Creates a new instance of the uniVocity writer.
     *
     * @param writer   Output writer to use
     * @param settings Writer settings to use
     * @return New uinstance of the uniVocity writer
     */
    protected abstract W createWriter(Writer writer, CWS settings);

    /**
     * Creates a new instance of the parser settings.
     *
     * @return New instance of the parser settings
     */
    protected abstract CPS createParserSettings();

    /**
     * Configure the parser settings.
     *
     * @param settings Parser settings to configure
     */
    protected void configureParserSettings(CPS settings) {
        configureCommonSettings(settings);

        if (headerExtractionEnabled != null) {
            settings.setHeaderExtractionEnabled(headerExtractionEnabled);
        }
        if (numberOfRecordsToRead != null) {
            settings.setNumberOfRecordsToRead(numberOfRecordsToRead);
        }
    }

    /**
     * Creates a new instance of the uniVocity parser.
     *
     * @param settings Parser settings to use
     * @return New instance of the uniVocity parser
     */
    protected abstract P createParser(CPS settings);

    /**
     * Configures the format.
     *
     * @param format format to configure
     */
    protected void configureFormat(F format) {
        if (lineSeparator != null) {
            format.setLineSeparator(lineSeparator);
        }
        if (normalizedLineSeparator != null) {
            format.setNormalizedNewline(normalizedLineSeparator);
        }
        if (comment != null) {
            format.setComment(comment);
        }
    }

    /**
     * Resets the caches. It <b>must</b> be called when a setter is called.
     */
    protected void reset() {
        writerSettings = null;
        marshaller = null;
        parserSettings = null;
        unmarshaller = null;
    }

    /**
     * Creates and configures the writer settings.
     *
     * @return new configured instance of the writer settings
     */
    final CWS createAndConfigureWriterSettings() {
        CWS settings = createWriterSettings();
        configureWriterSettings(settings);
        configureFormat(settings.getFormat());
        return settings;
    }

    /**
     * Creates and configures the parser settings.
     *
     * @return new configured instance of the parser settings
     */
    final CPS createAndConfigureParserSettings() {
        CPS settings = createParserSettings();
        configureParserSettings(settings);
        configureFormat(settings.getFormat());
        return settings;
    }

    /**
     * Configures the common settings shared by parser and writer.
     *
     * @param settings settings to configure
     */
    private void configureCommonSettings(CommonSettings<F> settings) {
        if (nullValue != null) {
            settings.setNullValue(nullValue);
        }
        if (skipEmptyLines != null) {
            settings.setSkipEmptyLines(skipEmptyLines);
        }
        if (ignoreTrailingWhitespaces != null) {
            settings.setIgnoreTrailingWhitespaces(ignoreTrailingWhitespaces);
        }
        if (ignoreLeadingWhitespaces != null) {
            settings.setIgnoreLeadingWhitespaces(ignoreLeadingWhitespaces);
        }
        if (headersDisabled) {
            settings.setHeaders((String[]) null);
        } else if (headers != null) {
            settings.setHeaders(headers);
        }
    }

    /**
     * Returns {@code this} as the proper data format type. It helps the fluent API with inheritance.
     *
     * @return {@code this} as the proper data format type
     */
    @SuppressWarnings("unchecked")
    private DF self() {
        return (DF) this;
    }
}
