package com.conveyal.osmlib;

import com.google.common.primitives.Ints;
import com.google.protobuf.ByteString;
import com.google.protobuf.GeneratedMessageLite;
import org.openstreetmap.osmosis.osmbinary.Fileformat;
import org.openstreetmap.osmosis.osmbinary.Osmformat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.SynchronousQueue;
import java.util.zip.Deflater;

/**
 * Consumes OSM entity objects and writes a stream of PBF data blocks to the specified output stream.
 * This is neither threadsafe nor reentrant! Create one instance of this encoder per encode operation.
 */
public class PBFOutput implements OSMEntitySink, Runnable {

    private static final Logger LOG = LoggerFactory.getLogger(PBFOutput.class);

    /** The underlying output stream where VEX data will be written. */
    private OutputStream downstream;

    /** The replication timestamp to record in the PBF file. Should be set before writing begins. */
    private long timestamp;

    /** Values retained from one entity to the next within a block for delta decoding. */
    private long prevId, prevFixedLat, prevFixedLon;

    /** The entity type of the current block to enforce grouping of entities by type. null means no type yet. */
    private OSMEntity.Type currEntityType = null;

    /* Per-block state */

    private final StringTable stringTable = new StringTable();

    private int nEntitiesInBlock;

    private Osmformat.PrimitiveGroup.Builder primitiveGroupBuilder;

    private Osmformat.DenseNodes.Builder denseNodesBuilder;

    private Thread writerThread = null;

    /** Construct a new PBF output encoder which writes to the given downstream OutputStream. */
    public PBFOutput(OutputStream downstream) {
        this.downstream = downstream;
    }

    /** Reset the inter-entity delta coding values and set up a new block. */
    private void beginBlock(OSMEntity.Type eType) throws IOException {
        prevId = prevFixedLat = prevFixedLon = nEntitiesInBlock = 0;
        stringTable.clear();
        primitiveGroupBuilder = Osmformat.PrimitiveGroup.newBuilder();
        if (eType == OSMEntity.Type.NODE) {
            denseNodesBuilder = Osmformat.DenseNodes.newBuilder();
        }
    }

    /** We always add one primitive group of less that 8k elements to each primitive block. */
    private void endBlock () {
        if (nEntitiesInBlock > 0) {
            if (currEntityType == OSMEntity.Type.NODE) {
                primitiveGroupBuilder.setDense(denseNodesBuilder);
            }
            // Pass the block off to the compression/writing thread
            try {
                Osmformat.PrimitiveBlock primitiveBlock = Osmformat.PrimitiveBlock.newBuilder()
                        .setStringtable(stringTable.toBuilder()).addPrimitivegroup(primitiveGroupBuilder).build();
                synchronousQueue.put(primitiveBlock);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    /** @param block is either a PrimitiveBlock or a HeaderBlock */
    private void writeOneBlob(GeneratedMessageLite block) {

        // FIXME lotsa big copies going on here

        String blobTypeString;
        if (block instanceof Osmformat.HeaderBlock) {
            blobTypeString = "OSMHeader";
        } else if (block instanceof Osmformat.PrimitiveBlock) {
            blobTypeString = "OSMData";
        } else {
            throw new AssertionError("block must be either a header block or a primitive block.");
        }

        Fileformat.Blob.Builder blobBuilder = Fileformat.Blob.newBuilder();
        byte[] serializedBlock = block.toByteArray();
        byte[] deflatedBlock = new byte[serializedBlock.length];
        int deflatedSize = deflate(serializedBlock, deflatedBlock);
        if (deflatedSize < 0) {
            LOG.debug("Deflate did not reduce the size of a block. Saving it uncompressed.");
            blobBuilder.setRaw(ByteString.copyFrom(serializedBlock));
        } else {
            blobBuilder.setZlibData(ByteString.copyFrom(deflatedBlock, 0, deflatedSize));
            blobBuilder.setRawSize(serializedBlock.length);
        }
        byte[] serializedBlob = blobBuilder.build().toByteArray();

        Fileformat.BlobHeader blobHeader = Fileformat.BlobHeader.newBuilder()
                .setType(blobTypeString).setDatasize(serializedBlob.length).build();
        byte[] serializedBlobHeader = blobHeader.toByteArray();
        try {
            // "Returns a big-endian representation of value in a 4-element byte array"
            downstream.write(Ints.toByteArray(serializedBlobHeader.length));
            downstream.write(serializedBlobHeader);
            downstream.write(serializedBlob);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    /**
     * Called at the beginning of each node, way, or relation to enforce grouping of entities by type.
     * If the entity type has changed since the last entity (except at the beginning of the first block),
     * ends the block and starts a new one of the new type. Each block will contain entities of only a single type.
     */
    private void checkBlockTransition(OSMEntity.Type eType) throws IOException {
        if (currEntityType != eType || nEntitiesInBlock++ >= 8000) {
            if (currEntityType != null) {
                endBlock();
            }
            currEntityType = eType;
            beginBlock(eType);
        }
    }

    /**
     * Deflate the given input data buffer into the given output byte buffer.
     * Used in both PBF and VEX output.
     * @return the deflated size of the data, or -1 if deflate did not reduce the data size.
     */
    public static int deflate (byte[] input, byte[] output) {
        int pos = 0;
        // Do not compress an empty data block, it will spin forever trying to fill the zero-length output buffer.
        if (input.length > 0) {
            Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, false); // include gzip header and checksum
            deflater.setInput(input, 0, input.length);
            deflater.finish(); // There will be no more input after this byte array.
            while (!deflater.finished()) {
                pos += deflater.deflate(output, pos, output.length - pos, Deflater.SYNC_FLUSH);
                if (pos >= input.length) {
                    return -1; // compressed output is bigger than buffer, store uncompressed
                }
            }
        }
        return pos;
    }

    /* OSM DATA SINK INTERFACE */

    @Override
    public void writeBegin() throws IOException {

        LOG.info("Writing PBF format...");

        // Write out a header block
        Osmformat.HeaderBlock.Builder builder = Osmformat.HeaderBlock.newBuilder();
        builder.addRequiredFeatures("DenseNodes").setWritingprogram("Vanilla Extract").build();
        if (timestamp > 0) {
            builder.setOsmosisReplicationTimestamp(timestamp);
        }
        writeOneBlob(builder.build());

        // Start another thread that will handle compression and writing in parallel.
        writerThread = new Thread(this);
        writerThread.start();

    }

    @Override
    public void setReplicationTimestamp(long secondsSinceEpoch) {
        this.timestamp = secondsSinceEpoch;
    }

    @Override
    public void writeEnd() throws IOException {
        // Finish any partially-completed block.
        endBlock();
        // Send a primitive block with no primitive group to the writer thread, signaling it to shut down and clean up.
        try {
            synchronousQueue.put(Osmformat.PrimitiveBlock.getDefaultInstance());
            writerThread.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        LOG.info("Finished writing PBF format.");
    }

    @Override
    public void writeNode(long id, Node node) throws IOException {

        checkBlockTransition(OSMEntity.Type.NODE);

        long idDelta = id - prevId;
        prevId = id;
        denseNodesBuilder.addId(idDelta);

        /* Fixed-precision latitude and longitude */
        long fixedLat = (long) (node.fixedLat); // TODO are longs necessary?
        long fixedLon = (long) (node.fixedLon);
        long fixedLatDelta = fixedLat - prevFixedLat;
        long fixedLonDelta = fixedLon - prevFixedLon;
        prevFixedLat = fixedLat;
        prevFixedLon = fixedLon;
        denseNodesBuilder.addLat(fixedLatDelta).addLon(fixedLonDelta);

        /* Tags for a whole dense node block are stored as: key, val, key, val, 0, key, val, key, val, 0 */
        if (node.tags != null) {
            for (OSMEntity.Tag tag : node.tags) {
                if (tag.value == null) tag.value = "";
                int keyCode = stringTable.getCode(tag.key);
                int valCode = stringTable.getCode(tag.value);
                denseNodesBuilder.addKeysVals(keyCode);
                denseNodesBuilder.addKeysVals(valCode);
            }
        }
        denseNodesBuilder.addKeysVals(0);

    }

    @Override
    public void writeWay(long id, Way way) throws IOException {

        checkBlockTransition(OSMEntity.Type.WAY);
        Osmformat.Way.Builder builder = Osmformat.Way.newBuilder().setId(id);

        /* Tags */
        if (way.tags != null) {
            for (OSMEntity.Tag tag : way.tags) {
                if (tag.value == null) tag.value = "";
                builder.addKeys(stringTable.getCode(tag.key));
                builder.addVals(stringTable.getCode(tag.value));
            }
        }

        /* Node References */
        long prevNodeRef = 0;
        for (long ref : way.nodes) {
            builder.addRefs(ref - prevNodeRef); // delta-coded node references
            prevNodeRef = ref;
        }

        /* TODO Should we trigger the build here or just call with the builder? */
        primitiveGroupBuilder.addWays(builder.build());

    }

    @Override
    public void writeRelation(long id, Relation relation) throws IOException {

        checkBlockTransition(OSMEntity.Type.RELATION);
        Osmformat.Relation.Builder builder = Osmformat.Relation.newBuilder().setId(id);

        /* Tags */
        if (relation.tags != null) {
            for (OSMEntity.Tag tag : relation.tags) {
                if (tag.value == null) tag.value = "";
                builder.addKeys(stringTable.getCode(tag.key));
                builder.addVals(stringTable.getCode(tag.value));
            }
        }

        /* Relation members */
        long lastMemberId = 0;
        for (Relation.Member member : relation.members) {
            builder.addMemids(member.id - lastMemberId); // delta-coded member references
            lastMemberId = member.id;
            builder.addRolesSid(stringTable.getCode(member.role));
            Osmformat.Relation.MemberType memberType;
            if (member.type == OSMEntity.Type.NODE) {
                memberType = Osmformat.Relation.MemberType.NODE;
            } else if (member.type == OSMEntity.Type.WAY) {
                memberType = Osmformat.Relation.MemberType.WAY;
            } else if (member.type == OSMEntity.Type.RELATION) {
                memberType = Osmformat.Relation.MemberType.RELATION;
            } else {
                throw new RuntimeException("Member type was not defined.");
            }
            builder.addTypes(memberType);
        }

        /* TODO Should we trigger the build here or just call with the builder? */
        primitiveGroupBuilder.addRelations(builder.build());

    }

    /** A zero-length BlockingQueue that hands tasks to the compression/writing thread. */
    private final SynchronousQueue<Osmformat.PrimitiveBlock> synchronousQueue = new SynchronousQueue<>();

    /** Runnable interface implementation that compresses and writes output blocks asynchronously. */
    @Override
    public void run() {
        while (true) {
            try {
                Osmformat.PrimitiveBlock block = synchronousQueue.take(); // block until work is available
                if (block.getPrimitivegroupCount() == 0) {
                    break; // a block with no primitive groups tells the writer thread to shut down.
                }
                writeOneBlob(block);
            } catch (InterruptedException ex) {
                LOG.error("Block writer thread was interrupted while waiting for work.");
                break;
            }
        }
        try {
            downstream.flush();
            downstream.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}