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

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.util.EnumMap;
import java.util.Map;

import javax.imageio.ImageIO;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.Result;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.datamatrix.encoder.SymbolShapeHint;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;

import org.apache.camel.Exchange;
import org.apache.camel.NoTypeConversionAvailableException;
import org.apache.camel.TypeConversionException;
import org.apache.camel.spi.DataFormat;
import org.apache.camel.util.ExchangeHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * {@link DataFormat} to create (encode) and 
 * read (decode) barcodes. For more info about
 * the available barcodes have a look at:<br/><br/>
 * 
 * https://github.com/zxing/zxing
 * 
 */
public class BarcodeDataFormat implements DataFormat {

    /**
     * Logger.
     */
    private static final Logger LOG = LoggerFactory.getLogger(BarcodeDataFormat.class);
    
    /**
     * The bean for the default parameters.
     */
    private BarcodeParameters params;
    
    /**
     * The encoding hint map, used for writing a barcode.
     */
    private final Map<EncodeHintType, Object> writerHintMap = 
            new EnumMap<EncodeHintType, Object>(EncodeHintType.class);
    
    /**
     * The decoding hint map, used for reading a barcode.
     */
    private final Map<DecodeHintType, Object> readerHintMap = 
            new EnumMap<DecodeHintType, Object>(DecodeHintType.class);
            

    /**
     * Create instance with default parameters.
     */
    public BarcodeDataFormat() {
        this.setDefaultParameters();
        this.optimizeHints();
    }
    
    /**
     * Create instance with custom {@link BarcodeFormat}. The other 
     * values are default.
     * 
     * @param format the barcode format
     */
    public BarcodeDataFormat(final BarcodeFormat format) {
        this.setDefaultParameters();
        this.params.setFormat(format);
        this.optimizeHints();
    }

    /**
     * Create instance with custom height and width. The other 
     * values are default.
     * 
     * @param height the image height
     * @param width the image width
     */
    public BarcodeDataFormat(final int width, final int height) {
        this.setDefaultParameters();
        this.params.setHeight(height);
        this.params.setWidth(width);
        this.optimizeHints();
    }

    /**
     * Create instance with custom {@link BarcodeImageType}. The other 
     * values are default.
     * 
     * @param type the type (format) of the image. e.g. PNG
     */
    public BarcodeDataFormat(final BarcodeImageType type) {
        this.setDefaultParameters();
        this.params.setType(type);
        this.optimizeHints();
    }
    
    /**
     * Create instance with custom height, width and image type. The other 
     * values are default.
     * 
     * @param height the image height
     * @param width the image width
     * @param type the type (format) of the image. e.g. PNG
     * @param format the barcode format
     */
    public BarcodeDataFormat(final int width, final int height
            , final BarcodeImageType type, final BarcodeFormat format) {
        this.setDefaultParameters();
        this.params.setHeight(height);
        this.params.setWidth(width);
        this.params.setType(type);
        this.params.setFormat(format);
        this.optimizeHints();
    }
    
    /**
     * Marshall a {@link String} payload to a code image.
     * 
     * @param exchange
     * @param graph
     * @param stream
     * @throws Exception 
     */
    @Override
    public void marshal(final Exchange exchange, final Object graph
            , final OutputStream stream) throws Exception {
        this.printImage(exchange, graph, stream);
    }

    /**
     * Unmarshall a code image to a {@link String} payload.
     * 
     * @param exchange
     * @param stream
     * @return
     * @throws Exception 
     */
    @Override
    public Object unmarshal(final Exchange exchange, final InputStream stream) throws Exception {
        return this.readImage(exchange, stream);
    }
    
    /**
     * Sets the default parameters.
     */
    protected final void setDefaultParameters() {
        this.params = new BarcodeParameters();
    }
    
    /**
     * Sets hints optimized for different barcode types.
     */
    protected final void optimizeHints() {
        // clear hints for re-optimization
        this.writerHintMap.clear();
        this.readerHintMap.clear();

        // writer hints
        String format = this.params.getFormat().toString();

        // only for QR code. AZTEC uses zxing's default error correction 33%.
        if (format.equals(BarcodeFormat.QR_CODE.toString())) {
            this.writerHintMap
                  .put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
        }

        if (format.equals(BarcodeFormat.DATA_MATRIX.toString())) {
            this.writerHintMap
                    .put(EncodeHintType.DATA_MATRIX_SHAPE
                            , SymbolShapeHint.FORCE_SQUARE);
        }
        
        // reader hints
        this.readerHintMap.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
    }
    
    /**
     * Writes the image file to the output stream.
     * 
     * @param graph the object graph
     * @param exchange the camel exchange
     * @param stream the output stream
     * @throws WriterException
     * @throws UnsupportedEncodingException
     * @throws IOException 
     * @throws org.apache.camel.NoTypeConversionAvailableException 
     */
    private void printImage(final Exchange exchange, final Object graph, final OutputStream stream)
        throws WriterException, UnsupportedEncodingException, IOException, TypeConversionException,
        NoTypeConversionAvailableException {
         
        final String payload = ExchangeHelper
                .convertToMandatoryType(exchange, String.class, graph);
        final MultiFormatWriter writer = new MultiFormatWriter();

        // set values
        final String type = this.params.getType().toString();
        
        // create code image  
        final BitMatrix matrix = writer.encode(
                payload,
                this.params.getFormat(), 
                this.params.getWidth(), 
                this.params.getHeight(), 
                writerHintMap);
        
        // write image back to stream
        MatrixToImageWriter.writeToStream(matrix, type, stream);
    }
    
    /**
     * Reads the message from a code.
     * 
     * @param exchange
     * @param stream
     * @return
     * @throws Exception
     */
    private String readImage(final Exchange exchange, final InputStream stream)
        throws Exception {
        final MultiFormatReader reader = new MultiFormatReader();
        final BufferedInputStream in = exchange.getContext()
                .getTypeConverter()
                .mandatoryConvertTo(BufferedInputStream.class, stream);
        final BinaryBitmap bitmap = new BinaryBitmap(
                new HybridBinarizer(
                        new BufferedImageLuminanceSource(ImageIO.read(in))));
        final Result result = reader.decode(bitmap, readerHintMap);
        
        // write the found barcode format into the header
        exchange.getOut()
                .setHeader(Barcode.BARCODE_FORMAT, result.getBarcodeFormat());
        
        return result.getText();
    }
    
    /**
     * Adds a new hint value to writer (encode) hint map.
     * 
     * @param hintType
     * @param value 
     */
    public final void addToHintMap(final EncodeHintType hintType
            , final Object value) {
        this.writerHintMap.put(hintType, value);
        LOG.info(
                String.format("Added '%s' with value '%s' to writer hint map."
                        , hintType.toString(), value.toString()));
    }
    
    /**
     * Adds a new hint value to reader (decode) hint map.
     * 
     * @param hintType
     * @param value 
     */
    public final void addToHintMap(final DecodeHintType hintType
            , final Object value) {
        this.readerHintMap.put(hintType, value);
    }
    
    /**
     * Removes a hint from writer (encode) hint map.
     * 
     * @param hintType 
     */
    public final void removeFromHintMap(final EncodeHintType hintType) {
        if (this.writerHintMap.containsKey(hintType)) {
            this.writerHintMap.remove(hintType);
            LOG.info(
                    String.format("Removed '%s' from writer hint map."
                            , hintType.toString()));
        } else {
            LOG.warn(
                    String.format("Could not find encode hint type '%s' in "
                            + "writer hint map.", hintType.toString()));
        }
    }
    
    /**
     * Removes a hint from reader (decode) hint map.
     * 
     * @param hintType 
     */
    public final void removeFromHintMap(final DecodeHintType hintType) {
        if (this.readerHintMap.containsKey(hintType)) {
            this.readerHintMap.remove(hintType);
            LOG.info(
                    String.format("Removed '%s' from reader hint map."
                            , hintType.toString()));
        } else {
            LOG.warn(
                    String.format("Could not find decode hint type '%s' in"
                            + " reader hint map.", hintType.toString()));
        }
    }

    /**
     * The (default) parameters.
     * 
     * @return 
     */
    public final BarcodeParameters getParams() {
        return params;
    }

    /**
     * The writer (encode) hint map.
     * 
     * @return 
     */
    public final Map<EncodeHintType, Object> getWriterHintMap() {
        return writerHintMap;
    }

    /**
     * The reader (decode) hint map.
     * 
     * @return 
     */
    public final Map<DecodeHintType, Object> getReaderHintMap() {
        return readerHintMap;
    }
    
    // these set method is used for BarcodeDataFormat XML DSL
    public void setBarcodeImageType(BarcodeImageType type) {
        this.params.setType(type);
        this.optimizeHints();
    }
    
    public void setBarcodeFormat(BarcodeFormat format) {
        this.params.setFormat(format);
        this.optimizeHints();
    }
    
    public void setWidth(Integer width) {
        this.params.setWidth(width);
    }
    
    public void setHeight(Integer height) {
        this.params.setHeight(height);
    }
}
