/*
 * Licensed 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 com.facebook.presto.geospatial;

import com.esri.core.geometry.ogc.OGCGeometry;
import com.facebook.presto.operator.scalar.AbstractTestFunctions;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
import org.testng.annotations.Test;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.facebook.presto.geospatial.BingTile.MAX_ZOOM_LEVEL;
import static com.facebook.presto.geospatial.BingTile.fromCoordinates;
import static com.facebook.presto.geospatial.BingTileUtils.MAX_LATITUDE;
import static com.facebook.presto.geospatial.BingTileUtils.MAX_LONGITUDE;
import static com.facebook.presto.geospatial.BingTileUtils.MIN_LATITUDE;
import static com.facebook.presto.geospatial.BingTileUtils.MIN_LONGITUDE;
import static com.facebook.presto.geospatial.BingTileUtils.findDissolvedTileCovering;
import static com.facebook.presto.geospatial.BingTileUtils.tileXToLongitude;
import static com.facebook.presto.geospatial.BingTileUtils.tileYToLatitude;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static java.lang.String.format;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.testng.Assert.assertEquals;

public class TestBingTile
        extends AbstractTestFunctions
{
    @Test
    public void testSerialization()
            throws Exception
    {
        ObjectMapper objectMapper = new ObjectMapper();
        BingTile tile = fromCoordinates(1, 2, 3);
        String json = objectMapper.writeValueAsString(tile);
        assertEquals("{\"x\":1,\"y\":2,\"zoom\":3}", json);
        assertEquals(tile, objectMapper.readerFor(BingTile.class).readValue(json));
    }

    @Test
    public void testBingTileEncoding()
    {
        for (int zoom = 0; zoom <= MAX_ZOOM_LEVEL; zoom++) {
            int maxValue = (1 << zoom) - 1;
            testEncodingRoundTrip(0, 0, zoom);
            testEncodingRoundTrip(0, maxValue, zoom);
            testEncodingRoundTrip(maxValue, 0, zoom);
            testEncodingRoundTrip(maxValue, maxValue, zoom);
        }
    }

    private void testEncodingRoundTrip(int x, int y, int zoom)
    {
        BingTile expected = BingTile.fromCoordinates(x, y, zoom);
        BingTile actual = BingTile.decode(expected.encode());
        assertEquals(actual, expected);
    }

    @Test
    public void testTileXToLongitude()
    {
        assertEquals(tileXToLongitude(0, 0), MIN_LONGITUDE);
        assertEquals(tileXToLongitude(1, 0), MAX_LONGITUDE);
        assertEquals(tileXToLongitude(0, 1), MIN_LONGITUDE);
        assertEquals(tileXToLongitude(1, 1), 0.0);
        assertEquals(tileXToLongitude(2, 1), MAX_LONGITUDE);
        for (int zoom = 2; zoom <= MAX_ZOOM_LEVEL; zoom++) {
            assertEquals(tileXToLongitude(0, zoom), MIN_LONGITUDE);
            assertEquals(tileXToLongitude(1 << (zoom - 1), zoom), 0.0);
            assertEquals(tileXToLongitude(1 << zoom, zoom), MAX_LONGITUDE);
        }
    }

    @Test
    public void testTileYToLatitude()
    {
        double delta = 1e-8;
        assertEquals(tileYToLatitude(0, 0), MAX_LATITUDE, delta);
        assertEquals(tileYToLatitude(1, 0), MIN_LATITUDE, delta);
        assertEquals(tileYToLatitude(0, 1), MAX_LATITUDE, delta);
        assertEquals(tileYToLatitude(1, 1), 0.0);
        assertEquals(tileYToLatitude(2, 1), MIN_LATITUDE, delta);
        for (int zoom = 2; zoom <= MAX_ZOOM_LEVEL; zoom++) {
            assertEquals(tileYToLatitude(0, zoom), MAX_LATITUDE, delta);
            assertEquals(tileYToLatitude(1 << (zoom - 1), zoom), 0.0);
            assertEquals(tileYToLatitude(1 << zoom, zoom), MIN_LATITUDE, delta);
        }
    }

    @Test
    public void testFindChildren()
    {
        assertEquals(
                toSortedQuadkeys(BingTile.fromQuadKey("").findChildren()),
                ImmutableList.of("0", "1", "2", "3"));

        assertEquals(
                toSortedQuadkeys(BingTile.fromQuadKey("0123").findChildren()),
                ImmutableList.of("01230", "01231", "01232", "01233"));

        assertEquals(
                toSortedQuadkeys(BingTile.fromQuadKey("").findChildren(2)),
                ImmutableList.of("00", "01", "02", "03", "10", "11", "12", "13", "20", "21", "22", "23", "30", "31", "32", "33"));

        assertThatThrownBy(() -> BingTile.fromCoordinates(0, 0, MAX_ZOOM_LEVEL).findChildren())
                .hasMessage(format("newZoom must be less than or equal to %s: %s", MAX_ZOOM_LEVEL, MAX_ZOOM_LEVEL + 1));

        assertThatThrownBy(() -> BingTile.fromCoordinates(0, 0, 13).findChildren(MAX_ZOOM_LEVEL + 1))
                .hasMessage(format("newZoom must be less than or equal to %s: %s", MAX_ZOOM_LEVEL, MAX_ZOOM_LEVEL + 1));

        assertThatThrownBy(() -> BingTile.fromCoordinates(0, 0, 13).findChildren(12))
                .hasMessage(format("newZoom must be greater than or equal to current zoom %s: %s", 13, 12));
    }

    private List<String> toSortedQuadkeys(List<BingTile> tiles)
    {
        return tiles.stream()
                .map(BingTile::toQuadKey)
                .sorted()
                .collect(toImmutableList());
    }

    @Test
    public void testFindParent()
    {
        assertEquals(BingTile.fromQuadKey("0123").findParent().toQuadKey(), "012");
        assertEquals(BingTile.fromQuadKey("1").findParent().toQuadKey(), "");
        assertEquals(BingTile.fromQuadKey("0123").findParent(1).toQuadKey(), "0");
        assertEquals(BingTile.fromQuadKey("0123").findParent(4).toQuadKey(), "0123");

        assertThatThrownBy(() -> BingTile.fromQuadKey("0123").findParent(5))
                .hasMessage(format("newZoom must be less than or equal to current zoom %s: %s", 4, 5));

        assertThatThrownBy(() -> BingTile.fromQuadKey("").findParent())
                .hasMessage(format("newZoom must be greater than or equal to 0: %s", -1));

        assertThatThrownBy(() -> BingTile.fromQuadKey("12").findParent(-1))
                .hasMessage(format("newZoom must be greater than or equal to 0: %s", -1));
    }

    @Test
    public void testFindDissolvedTileCovering()
    {
        assertTileCovering("POINT EMPTY", 0, ImmutableList.of());
        assertTileCovering("POINT EMPTY", 10, ImmutableList.of());
        assertTileCovering("POINT EMPTY", 20, ImmutableList.of());

        assertSmallSquareCovering(2);
        assertSmallSquareCovering(5);
        assertSmallSquareCovering(11);
        assertSmallSquareCovering(MAX_ZOOM_LEVEL);

        // Geometries at tile borders
        assertTileCovering("POINT (0 0)", 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (%s 0)", MIN_LONGITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (%s 0)", MAX_LONGITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (0 %s)", MIN_LATITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (0 %s)", MAX_LATITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (%s %s)", MIN_LONGITUDE, MIN_LATITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (%s %s)", MIN_LONGITUDE, MAX_LATITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (%s %s)", MAX_LONGITUDE, MAX_LATITUDE), 0, ImmutableList.of(""));
        assertTileCovering(format("POINT (%s %s)", MAX_LONGITUDE, MIN_LATITUDE), 0, ImmutableList.of(""));

        assertTileCovering("POINT (0 0)", 1, ImmutableList.of("3"));
        assertTileCovering(format("POINT (%s 0)", MIN_LONGITUDE), 1, ImmutableList.of("2"));
        assertTileCovering(format("POINT (%s 0)", MAX_LONGITUDE), 1, ImmutableList.of("3"));
        assertTileCovering(format("POINT (0 %s)", MIN_LATITUDE), 1, ImmutableList.of("3"));
        assertTileCovering(format("POINT (0 %s)", MAX_LATITUDE), 1, ImmutableList.of("1"));
        assertTileCovering(format("POINT (%s %s)", MIN_LONGITUDE, MIN_LATITUDE), 1, ImmutableList.of("2"));
        assertTileCovering(format("POINT (%s %s)", MIN_LONGITUDE, MAX_LATITUDE), 1, ImmutableList.of("0"));
        assertTileCovering(format("POINT (%s %s)", MAX_LONGITUDE, MAX_LATITUDE), 1, ImmutableList.of("1"));
        assertTileCovering(format("POINT (%s %s)", MAX_LONGITUDE, MIN_LATITUDE), 1, ImmutableList.of("3"));

        assertTileCovering("LINESTRING (-1 0, -2 0)", 1, ImmutableList.of("2"));
        assertTileCovering("LINESTRING (1 0, 2 0)", 1, ImmutableList.of("3"));
        assertTileCovering("LINESTRING (0 -1, 0 -2)", 1, ImmutableList.of("3"));
        assertTileCovering("LINESTRING (0 1, 0 2)", 1, ImmutableList.of("1"));

        assertTileCovering(format("LINESTRING (%s 1, %s 2)", MIN_LONGITUDE, MIN_LONGITUDE), 1, ImmutableList.of("0"));
        assertTileCovering(format("LINESTRING (%s -1, %s -2)", MIN_LONGITUDE, MIN_LONGITUDE), 1, ImmutableList.of("2"));
        assertTileCovering(format("LINESTRING (%s 1, %s 2)", MAX_LONGITUDE, MAX_LONGITUDE), 1, ImmutableList.of("1"));
        assertTileCovering(format("LINESTRING (%s -1, %s -2)", MAX_LONGITUDE, MAX_LONGITUDE), 1, ImmutableList.of("3"));

        assertTileCovering("POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))", 6, ImmutableList.of("12222", "300000", "300001"));
        assertTileCovering("POLYGON ((0 0, 0 10, 10 10, 0 0))", 6, ImmutableList.of("122220", "122222", "122221", "300000"));
        assertTileCovering("POLYGON ((10 10, -10 10, -20 -15, 10 10))", 3, ImmutableList.of("033", "211", "122"));
        assertTileCovering("POLYGON ((10 10, -10 10, -20 -15, 10 10))", 6, ImmutableList.of("211102", "211120", "033321", "033323", "211101", "211103", "211121", "03333", "211110", "211112", "211111", "122220", "122222", "122221"));

        assertTileCovering("GEOMETRYCOLLECTION (POINT (60 30.12))", 10, ImmutableList.of("1230301230"));
        assertTileCovering("GEOMETRYCOLLECTION (POINT (60 30.12))", 15, ImmutableList.of("123030123010121"));
        assertTileCovering("GEOMETRYCOLLECTION (POLYGON ((10 10, -10 10, -20 -15, 10 10)))", 3, ImmutableList.of("033", "211", "122"));
        assertTileCovering("GEOMETRYCOLLECTION (POINT (60 30.12), POLYGON ((10 10, -10 10, -20 -15, 10 10)))", 3, ImmutableList.of("033", "211", "122", "123"));
        assertTileCovering("GEOMETRYCOLLECTION (POINT (60 30.12), LINESTRING (61 31, 61.01 31.01), POLYGON EMPTY)", 15, ImmutableList.of("123030123010121", "123030112310200", "123030112310202", "123030112310201"));
        assertTileCovering("GEOMETRYCOLLECTION (POINT (0.1 0.1), POINT(0.1 -0.1), POINT(-0.1 -0.1), POINT(-0.1 0.1))", 3,
                ImmutableList.of("033", "122", "211", "300"));
    }

    private void assertTileCovering(String wkt, int zoom, List<String> quadkeys)
    {
        OGCGeometry geometry = OGCGeometry.fromText(wkt);
        List<String> actual = findDissolvedTileCovering(geometry, zoom).stream().map(BingTile::toQuadKey).sorted().collect(toImmutableList());
        List<String> expected = ImmutableList.sortedCopyOf(quadkeys);
        assertEquals(actual, expected, format("Actual:%n%s%nExpected:%n%s", actual, expected));
    }

    private void assertSmallSquareCovering(int zoom)
    {
        int halfway = 1 << (zoom - 1);
        assertTileCovering(
                "POLYGON ((0.00001 0.00001, 0.00001 -0.00001, -0.00001 -0.00001, -0.00001 0.00001, 0.00001 0.00001))",
                zoom,
                Stream.of(
                        BingTile.fromCoordinates(halfway, halfway, zoom),
                        BingTile.fromCoordinates(halfway, halfway - 1, zoom),
                        BingTile.fromCoordinates(halfway - 1, halfway, zoom),
                        BingTile.fromCoordinates(halfway - 1, halfway - 1, zoom)
                ).map(BingTile::toQuadKey)
                        .collect(Collectors.toList()));
    }
}
