/*
 * Copyright (c) 2018 James Hughes, 2019 Gabriel Roldan, 2022 Aurélien Mino
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * and Eclipse Distribution License v. 1.0 which accompanies this distribution.
 * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
 * and the Eclipse Distribution License is available at
 *
 * http://www.eclipse.org/org/documents/edl-v10.php.
 */
package org.locationtech.jts.io.twkb;

import static org.locationtech.jts.io.twkb.Varint.writeSignedVarLong;
import static org.locationtech.jts.io.twkb.Varint.writeUnsignedVarInt;

import java.io.ByteArrayOutputStream;
import java.io.DataOutput;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Objects;

import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.LinearRing;
import org.locationtech.jts.geom.MultiLineString;
import org.locationtech.jts.geom.MultiPoint;
import org.locationtech.jts.geom.MultiPolygon;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.io.twkb.TWKBHeader.GeometryType;

/**
 * Writes {@link Geometry}s in TWKB (Tiny Well-known Binary) format.
 * <p>
 * The current TWKB specification is
 * <a href='https://github.com/TWKB/Specification/blob/master/twkb.md'>https://github.com/TWKB/Specification/blob/master/twkb.md</a>.
 * <p>
 */
public class TWKBWriter {

    private TWKBHeader paramsHeader = new TWKBHeader()
        .setXyPrecision(7)
        .setZPrecision(0)
        .setMPrecision(0)
        .setHasBBOX(false)
        .setHasSize(false);

    /**
     * Number of base-10 decimal places stored for X and Y dimensions.
     * <p>
     * A positive retaining information to the right of the decimal place, negative rounding up to
     * the left of the decimal place).
     * <p>
     * Defaults to {@code 7}
     */
    public TWKBWriter setXYPrecision(int xyprecision) {
        if (xyprecision < -7 || xyprecision > 7) {
            throw new IllegalArgumentException(
                    "X/Z precision cannot be greater than 7 or less than -7");
        }
        paramsHeader = paramsHeader.setXyPrecision(xyprecision);
        return this;
    }

    public TWKBWriter setEncodeZ(boolean includeZDimension) {
        paramsHeader = paramsHeader.setHasZ(includeZDimension);
        return this;
    }

    public TWKBWriter setEncodeM(boolean includeMDimension) {
        paramsHeader = paramsHeader.setHasM(includeMDimension);
        return this;
    }

    /**
     * Number of base-10 decimal places stored for Z dimension.
     * <p>
     * A positive retaining information to the right of the decimal place, negative rounding up to
     * the left of the decimal place).
     * <p>
     * Defaults to {@code 0}
     */
    public TWKBWriter setZPrecision(int zprecision) {
        if (zprecision < 0 || zprecision > 7) {
            throw new IllegalArgumentException("Z precision cannot be negative or greater than 7");
        }
        paramsHeader = paramsHeader.setZPrecision(zprecision);
        return this;
    }

    /**
     * Number of base-10 decimal places stored for M dimension.
     * <p>
     * A positive retaining information to the right of the decimal place, negative rounding up to
     * the left of the decimal place).
     * <p>
     * Defaults to {@code 0}
     */
    public TWKBWriter setMPrecision(int mprecision) {
        if (mprecision < 0 || mprecision > 7) {
            throw new IllegalArgumentException("M precision cannot be negative or greater than 7");
        }
        paramsHeader = paramsHeader.setMPrecision(mprecision);
        return this;
    }

    /**
     * Whether the generated TWKB should include the size in bytes of the geometry.
     */
    public TWKBWriter setIncludeSize(boolean includeSize) {
        paramsHeader = paramsHeader.setHasSize(includeSize);
        return this;
    }

    /**
     * Whether the generated TWKB should include a Bounding Box for the geometry.
     */
    public TWKBWriter setIncludeBbox(boolean includeBbox) {
        paramsHeader = paramsHeader.setHasBBOX(includeBbox);
        return this;
    }

    public byte[] write(Geometry geom) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        try {
            write(geom, out);
        } catch (IOException ex) {
            throw new RuntimeException("Unexpected IOException caught: " + ex.getMessage(), ex);
        }
        return out.toByteArray();
    }

    public void write(Geometry geom, OutputStream out) throws IOException {
        write(geom, (DataOutput) new DataOutputStream(out));
    }

    public void write(Geometry geom, DataOutput out) throws IOException {
        Objects.requireNonNull(geom, "geometry is null");
        write(geom, out, paramsHeader, false);
    }

    private TWKBHeader write(Geometry geometry, DataOutput out, TWKBHeader params,
        boolean forcePreserveHeaderDimensions) throws IOException {
        Objects.requireNonNull(geometry, "Geometry is null");
        Objects.requireNonNull(out, "DataOutput is null");
        Objects.requireNonNull(params, "TWKBHeader is null");

        TWKBHeader header = prepareHeader(geometry, new TWKBHeader(params), forcePreserveHeaderDimensions);

        if (header.hasSize()) {
            BufferedDataOutput bufferedBody = new BufferedDataOutput();
            writeGeometryBody(geometry, bufferedBody, header);
            int bodySize = bufferedBody.size();
            header = header.setGeometryBodySize(bodySize);
            writeHeaderTo(header, out);
            out.write(bufferedBody.content());
        } else {
            writeHeaderTo(header, out);
            writeGeometryBody(geometry, out, header);
        }
        return header;
    }

    private static void writeHeaderTo(TWKBHeader header, DataOutput out) throws IOException {
        Objects.requireNonNull(out);
        final int typeAndPrecisionHeader;
        final int metadataHeader;
        {
            final int geometryType = header.geometryType().getValue();
            final int precisionHeader = Varint.zigZagEncode(header.xyPrecision()) << 4;
            typeAndPrecisionHeader = precisionHeader | geometryType;

            metadataHeader = (header.hasBBOX() ? 0b00000001 : 0) //
                | (header.hasSize() ? 0b00000010 : 0)//
                | (header.hasIdList() ? 0b00000100 : 0)//
                | (header.hasExtendedPrecision() ? 0b00001000 : 0)//
                | (header.isEmpty() ? 0b00010000 : 0);
        }
        out.writeByte(typeAndPrecisionHeader);
        out.writeByte(metadataHeader);
        if (header.hasExtendedPrecision()) {
            int extendedDimsHeader = (header.hasZ() ? 0b00000001 : 0) | (header.hasM() ? 0b00000010 : 0);
            extendedDimsHeader |= header.zPrecision() << 2;
            extendedDimsHeader |= header.mPrecision() << 5;

            out.writeByte(extendedDimsHeader);
        }
        if (header.hasSize()) {
            writeUnsignedVarInt(header.geometryBodySize(), out);
        }
    }

    private TWKBHeader prepareHeader(Geometry geometry, TWKBHeader params,
        boolean forcePreserveHeaderDimensions) {

        final boolean isEmpty = geometry.isEmpty();
        final GeometryType geometryType = GeometryType.valueOf(geometry.getClass());
        TWKBHeader header = forcePreserveHeaderDimensions ? params : setDimensions(geometry, params);
        header.setEmpty(isEmpty);
        header.setGeometryType(geometryType);

        if (isEmpty && header.hasBBOX()) {
            header = header.setHasBBOX(false);
        }
        return header;
    }

    private void writeGeometryBody(Geometry geom, DataOutput out, TWKBHeader header)
        throws IOException {
        if (header.isEmpty()) {
            return;
        }
        if (header.hasBBOX()) {
            writeBbox(geom, out, header);
        }
        final GeometryType geometryType = GeometryType.valueOf(geom.getClass());
        switch (geometryType) {
            case POINT:
                writePoint((Point) geom, out, header);
                return;
            case LINESTRING:
                writeLineString((LineString) geom, out, header, new long[header.getDimensions()]);
                return;
            case POLYGON:
                writePolygon((Polygon) geom, out, header, new long[header.getDimensions()]);
                return;
            case MULTIPOINT:
                writeMultiPoint((MultiPoint) geom, out, header);
                return;
            case MULTILINESTRING:
                writeMultiLineString((MultiLineString) geom, out, header);
                return;
            case MULTIPOLYGON:
                writeMultiPolygon((MultiPolygon) geom, out, header);
                return;
            case GEOMETRYCOLLECTION:
                writeGeometryCollection((GeometryCollection) geom, out, header);
                return;
            default:
                break;
        }
    }

    private void writePoint(Point geom, DataOutput out, TWKBHeader header)
        throws IOException {
        assert !geom.isEmpty();
        CoordinateSequence seq = geom.getCoordinateSequence();
        int dimensions = header.getDimensions();
        for (int d = 0; d < dimensions; d++) {
            writeOrdinate(seq.getOrdinate(0, d), 0L, header.getPrecision(d), out);
        }
    }

    private void writeCoordinateSequence(CoordinateSequence coordinateSequence,
        DataOutput out, TWKBHeader header, long[] prev, int minNPoints) throws IOException {

        final int dimensions = header.getDimensions();
        long[] delta = new long[dimensions];
        int nPoints = 0;
        int nPointsRemaining = coordinateSequence.size();
        // Real number of points can't be determined beforehand, since duplicated points may be removed, so buffering is required
        BufferedDataOutput bufferedOut = new BufferedDataOutput();

        for (int coordIndex = 0; coordIndex < coordinateSequence.size(); coordIndex++) {
            long diff = 0;
            nPointsRemaining--;
            for (int ordinateIndex = 0; ordinateIndex < dimensions; ordinateIndex++) {
                int precision = header.getPrecision(ordinateIndex);
                double ordinate = coordinateSequence.getOrdinate(coordIndex, ordinateIndex);
                long preciseOrdinate = makePrecise(ordinate, precision);
                delta[ordinateIndex] = preciseOrdinate - prev[ordinateIndex];
                prev[ordinateIndex] = preciseOrdinate;
                diff += Math.abs(delta[ordinateIndex]);
            }
            if (coordIndex != 0 && diff == 0 && (nPoints + nPointsRemaining) > minNPoints) {
                // Skip this point
                continue;
            }

            for (int ordinateIndex = 0; ordinateIndex < header.getDimensions(); ordinateIndex++) {
                writeSignedVarLong(delta[ordinateIndex], bufferedOut);
            }
            nPoints++;
        }

        writeUnsignedVarInt(nPoints, out);
        out.write(bufferedOut.content());
    }

    private long writeOrdinate(double ordinate, long previousOrdinateValue, int precision, DataOutput out) throws IOException {
        long preciseOrdinate = makePrecise(ordinate, precision);
        long delta = preciseOrdinate - previousOrdinateValue;
        writeSignedVarLong(delta, out);
        return preciseOrdinate;
    }

    private long makePrecise(double value, int precision) {
        return Math.round(value * Math.pow(10, precision));
    }

    private void writeLineString(LineString geom, DataOutput out, TWKBHeader header,
        long[] prev) throws IOException {
        writeCoordinateSequence(geom.getCoordinateSequence(), out, header, prev, 3);
    }

    private void writePolygon(Polygon geom, DataOutput out, TWKBHeader header,
        long[] prev) throws IOException {
        if (geom.isEmpty()) {
            writeUnsignedVarInt(0, out);
            return;
        }
        final int numInteriorRing = geom.getNumInteriorRing();
        final int nrings = 1 + numInteriorRing;
        writeUnsignedVarInt(nrings, out);
        writeLinearRing(geom.getExteriorRing(), out, header, prev);
        for (int r = 0; r < numInteriorRing; r++) {
            writeLinearRing(geom.getInteriorRingN(r), out, header, prev);
        }
    }

    private void writeLinearRing(LinearRing geom, DataOutput out, TWKBHeader header,
        long[] prev) throws IOException {
        if (geom.isEmpty()) {
            writeUnsignedVarInt(0, out);
            return;
        }
        writeCoordinateSequence(geom.getCoordinateSequence(), out, header, prev, 3);
    }

    private void writeMultiPoint(MultiPoint geom, DataOutput out, TWKBHeader header)
        throws IOException {
        assert !geom.isEmpty();

        CoordinateSequence seq = geom.getFactory().getCoordinateSequenceFactory()
            .create(geom.getCoordinates());
        writeCoordinateSequence(seq, out, header, new long[header.getDimensions()], 2);
    }

    private void writeMultiLineString(MultiLineString geom, DataOutput out, TWKBHeader header) throws IOException {
        final int size = writeNumGeometries(geom, out);
        long[] prev = new long[header.getDimensions()];
        for (int i = 0; i < size; i++) {
            writeLineString((LineString) geom.getGeometryN(i), out, header, prev);
        }
    }

    private void writeMultiPolygon(MultiPolygon geom, DataOutput out, TWKBHeader header) throws IOException {
        final int size = writeNumGeometries(geom, out);
        long[] prev = new long[header.getDimensions()];
        for (int i = 0; i < size; i++) {
            writePolygon((Polygon) geom.getGeometryN(i), out, header, prev);
        }
    }

    private void writeGeometryCollection(GeometryCollection geom, DataOutput out, TWKBHeader header) throws IOException {
        final int size = writeNumGeometries(geom, out);
        for (int i = 0; i < size; i++) {
            Geometry geometryN = geom.getGeometryN(i);
            boolean forcePreserveDimensions = geometryN.isEmpty();
            write(geometryN, out, header, forcePreserveDimensions);
        }
    }

    private int writeNumGeometries(GeometryCollection geom, DataOutput out)
        throws IOException {
        int size = geom.getNumGeometries();
        writeUnsignedVarInt(size, out);
        return size;
    }

    private void writeBbox(Geometry geom, DataOutput out, TWKBHeader header)
        throws IOException {
        final int dimensions = header.getDimensions();
        final double[] boundsCoordinates = computeEnvelope(geom, dimensions);

        for (int d = 0; d < dimensions; d++) {
            final int precision = header.getPrecision(d);
            double min = boundsCoordinates[2 * d];
            double max = boundsCoordinates[2 * d + 1];
            long preciseMin = writeOrdinate(min, 0, precision, out);
            writeOrdinate(max, preciseMin, precision, out);
        }
    }

    private static double[] computeEnvelope(Geometry geom, int dimensions) {
        BoundsExtractor extractor = new BoundsExtractor(dimensions);
        geom.apply(extractor);
        return extractor.ordinates;
    }

    private static TWKBHeader setDimensions(Geometry g, TWKBHeader header) {
        if (g.isEmpty()) {
            return header.setHasZ(false).setHasM(false);
        }
        if (g instanceof Point) {
            return setDimensions(((Point) g).getCoordinateSequence(), header);
        }
        if (g instanceof LineString) {
            return setDimensions(((LineString) g).getCoordinateSequence(), header);
        }
        if (g instanceof Polygon) {
            return setDimensions(((Polygon) g).getExteriorRing().getCoordinateSequence(), header);
        }
        return setDimensions(g.getGeometryN(0), header);
    }

    private static TWKBHeader setDimensions(CoordinateSequence seq, TWKBHeader header) {
        boolean hasZ = seq.hasZ();
        boolean hasM = seq.hasM();
        return header.setHasZ(hasZ).setHasM(hasM);
    }

    private static class BufferedDataOutput extends DataOutputStream {

        public BufferedDataOutput() {
            super(new ByteArrayOutputStream());
        }

        public byte[] content() {
            return ((ByteArrayOutputStream) out).toByteArray();
        }
    }

}