#
# GeoTiler - library to create maps using tiles from a map provider
#
# Copyright (C) 2014-2021 by Artur Wroblewski <wrobell@riseup.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
#
# This file incorporates work covered by the following copyright and
# permission notice (restored, based on setup.py file from
# https://github.com/stamen/modestmaps-py):
#
#   Copyright (C) 2007-2013 by Michal Migurski and other contributors
#   License: BSD
#

"""
GeoTiler map functionality.
"""

import asyncio
import itertools
import math
import numbers
import logging
import typing as tp
from collections import namedtuple

from .provider import DEFAULT_PROVIDER, find_provider, MapProvider
from .geo import zoom_to
from .tile.io import fetch_tiles as _fetch_tiles
from .tile.img import render_image
from .util import div_ceil

logger = logging.getLogger(__name__)

MAX_ZOOM = 25

Tile = namedtuple('Tile', ['url', 'offset', 'img', 'error'])
Tile.__doc__ = """
Map tile.

If a tile download succeeded, then `Tile.img` contain map tile image data.
Otherwise, it is set to null and `Tile.error` is set to tile download
error.

:var url: Tile URL.
:var offset: Tile offest in a map image.
:var img: Tile image data.
:var error: Tile error information.
"""

class Map:
    """
    Map created from tiles and to be drawn as an image.

    The extent, zoom, center and image size can be changed at any time with
    appropriate properties.

    :var provider: Map tiles provider identificator (default `osm`) or
        object.
    :var _zoom: Map zoom attribute accessed via `zoom` property.
    :var _size: Map image size accessed via `size` property.
    :var origin: Tile coordinates at map zoom level of base tile.
    :var offset: Position of base tile relative to map center.
    """
    def __init__(
        self, extent=None, center=None, zoom=None, size=None,
        provider: tp.Union[str, MapProvider]=DEFAULT_PROVIDER
    ) -> None:
        """
        Create map.

        One of the following parameters combination is required

        - center, zoom and size
        - extent and size
        - extent and zoom

        If none of above parameters combination is provided, then `ValueError`
        exception is raised.

        :param extent: Map geographical extent.
        :param center: Map geographical center.
        :param zoom: Map zoom.
        :param size: Map image size.
        :param provider: Map tiles provider.
        """
        super().__init__()

        self._check_size(size)

        if isinstance(provider, MapProvider):
            self.provider = provider
        else:
            self.provider = find_provider(provider)

        self.origin = None
        self.offset = None

        self._zoom = zoom
        self._size = size

        if center is not None and extent is not None:
            raise ValueError(
                'Bad map coverage, center and extent can\'t both be set'
            )
        elif extent is not None and size is not None and zoom is not None:
            raise ValueError(
                'Bad map coverage, size and zoom can\'t be set together' \
                ' with extent'
            )
        elif center is not None and zoom is not None and size is not None:
            self._change_center_zoom(center, zoom)
        elif extent is not None and size is not None:
            self._change_extent_and_zoom(extent)
        elif extent is not None and zoom is not None:
            self.extent = extent
        else:
            raise ValueError(
                'Unknown combination of extent, center, zoom and size' \
                ' parameters'
            )

        assert self._zoom is not None
        assert self._size is not None
        assert self.origin is not None
        assert self.offset is not None

        if __debug__:
            logger.debug(
                'extent={map.extent}, center={map.center}, zoom={map.zoom},'
                ' size={map.size}, origin={map.origin}, offset={map.offset}'
                .format(map=self)
            )


    @property
    def extent(self):
        """
        Calculate map geographical extent.

        It is a tuple of 2 coordinates (longitude and latitude)

        - lower-bottom corner of the map
        - right-top corner of the map

        Setting map extent changes map image size.
        """
        w, h = self._size
        p1 = self.geocode((0, h))
        p2 = self.geocode((w, 0))
        return p1[0], p1[1], p2[0], p2[1]


    @extent.setter
    def extent(self, extent):
        p1 = extent[:2]
        p2 = extent[2:]

        projection = self.provider.projection
        c1 = projection.rev_geocode(p1)
        c1 = zoom_to(c1, projection.zoom, self._zoom)

        c2 = projection.rev_geocode(p2)
        c2 = zoom_to(c2, projection.zoom, self._zoom)

        width = abs(c1[0] - c2[0]) * self.provider.tile_width
        height = abs(c1[1] - c2[1]) * self.provider.tile_height

        col = (c1[0] + c2[0]) / 2
        row = (c1[1] + c2[1]) / 2

        map_origin, map_offset = calculateMapCenter(self.provider, (col, row))
        self.origin = map_origin
        self.offset = map_offset
        self._size = int(width), int(height)


    @property
    def center(self):
        """
        Calculate map geographical center.

        It is a tuple of two values - longitude and latitude.

        Setting map geographical center affects map geographical extent.
        """
        w, h = self._size
        return self.geocode((w / 2, h / 2))


    @center.setter
    def center(self, center):
        projection = self.provider.projection
        c = projection.rev_geocode(center)
        c = zoom_to(c, projection.zoom, self._zoom)

        map_origin, map_offset = calculateMapCenter(self.provider, c)
        self.origin = map_origin
        self.offset = map_offset


    @property
    def zoom(self):
        """
        Map zoom value.

        Setting map value does *not* affect any other map properties like
        extent, center or image size.
        """
        return self._zoom


    @zoom.setter
    def zoom(self, zoom):
        self._change_center_zoom(self.center, zoom)


    @property
    def size(self):
        """
        Size of the image containing map.

        It is a sequence of two integer values - width and height of the
        image.

        Setting size of the image affects map geographical extent.
        """
        return self._size


    @size.setter
    def size(self, size):
        self._check_size(size)
        self._size = size

    def _check_size(self, size):
        """
        Check if `size` parameter has correct type.
        """
        valid = size is None \
            or len(size) == 2 \
            and all(isinstance(v, numbers.Integral) for v in tuple(size))
        if not valid:
            raise TypeError('Size should be sequence of two integer values')

    def _change_center_zoom(self, center, zoom):
        """
        Recalculate map to have new geographical center and update zoom
        value.

        :param center: Map geographical center.
        :param zoom: Map zoom value.
        """
        projection = self.provider.projection
        c = projection.rev_geocode(center)
        c = zoom_to(c, projection.zoom, zoom)

        map_origin, map_offset = calculateMapCenter(self.provider, c)
        self.origin = map_origin
        self.offset = map_offset
        self._zoom = zoom


    def _change_extent_and_zoom(self, extent):
        """
        Recalculate map to have new geographical extent and calculate map
        zoom.

        :param extent: Map geographical extent.
        """
        width, height = self._size

        p1 = extent[:2]
        p2 = extent[2:]

        map_origin, map_offset, zoom = calculateMapExtent(self.provider, width, height, p1, p2)
        self.origin = map_origin
        self.offset = map_offset
        self._zoom = zoom


    def __str__(self):
        return 'Map({provider}, {_size}, {origin}, {offset})'.format(**self.__dict__)


    def rev_geocode(self, location):
        """
        Reverse geocode geographical location.

        The method calculates location position (x, y) on map image.

        :param location: Geographical location (longitude, latitude).
        """
        ox, oy = self.offset
        projection = self.provider.projection
        coord = projection.rev_geocode(location)
        coord = zoom_to(coord, projection.zoom, self._zoom)

        # distance from the known coordinate offset
        x = ox + self.provider.tile_width * (coord[0] - self.origin[0])
        y = oy + self.provider.tile_height * (coord[1] - self.origin[1])

        # because of the center/corner business
        w, h = self.size
        return x + w / 2, y + h / 2


    def geocode(self, point):
        """
        Geocode map image point.

        The method calculates geographical location (longitude, latitude)
        of image point.

        :param point: Image map point (x, y).
        """
        max_zoom = MAX_ZOOM
        max_coord = zoom_to(self.origin, self._zoom, max_zoom)

        w, h = self._size

        # because of the center/corner business
        x, y = point[0] - w / 2, point[1] - h / 2

        # distance in tile widths from reference tile to point
        xtiles = (x - self.offset[0]) / self.provider.tile_width
        ytiles = (y - self.offset[1]) / self.provider.tile_height

        # distance in rows & columns at maximum zoom
        xDistance = xtiles * math.pow(2, (MAX_ZOOM - self._zoom))
        yDistance = ytiles * math.pow(2, (MAX_ZOOM - self._zoom))

        # new point coordinate reflecting that distance
        x = max_coord[0] + xDistance
        y = max_coord[1] + yDistance
        coord = zoom_to((x, y), max_zoom, self._zoom)

        projection = self.provider.projection
        location = projection.geocode(coord, self._zoom)

        return location


def render_map(map, tiles=None, downloader=None, **kw):
    """
    Download map tiles and render map image.

    If tiles are specified, then the provided tiles are used to render map.
    Otherwise, map tiles are downloaded from map provider.

    If `downloader` is null, then default map tiles downloader is used
    (:py:func:`geotiler.tile.io.fetch_tiles`).

    The function returns an image (instance of `PIL.Image` class).

    :param map: Map instance.
    :param tiles: Optional map tiles.
    :param downloader: Map tiles downloader.
    :param kw: Parameters passed to the downloader.
    """
    task = render_map_async(map, downloader=downloader, **kw)
    loop = asyncio.get_event_loop()
    return loop.run_until_complete(task)

async def render_map_async(map, tiles=None, downloader=None, **kw):
    """
    Asyncio coroutine to download map tiles asynchronously and render map
    image.

    If tiles are specified, then the provided tiles are used to render map.
    Otherwise, map tiles are downloaded from map provider.

    If `downloader` is null, then default map tiles downloader is used
    (:py:func:`geotiler.tile.io.fetch_tiles`).

    The function returns an image (instance of `PIL.Image` class).

    :param map: Map instance.
    :param tiles: Optional map tiles.
    :param downloader: Map tiles downloader.
    :param kw: Parameters passed to the downloader.
    """
    if not tiles:
        tiles = fetch_tiles(map, downloader, **kw)
        tiles = (t async for t in tiles)
    return await render_image(map, tiles)

def fetch_tiles(map, downloader=None, **kw):
    """
    Create and fetch map tiles.

    Asynchronous generator of map tiles is returned.

    :param map: Map instance.
    :param downloader: Map tiles downloader.
    :param kw: Parameters passed to the downloader.
    """
    if downloader is None:
        downloader = _fetch_tiles

    tile_url = map.provider.tile_url

    coord, offset = _find_top_left_tile(map)
    coords = _tile_coords(map, coord, offset)
    offsets = _tile_offsets(map, offset)
    urls = (tile_url(c, map.zoom) for c in coords)
    tiles = (Tile(u, o, None, None) for u, o in zip(urls, offsets))
    return downloader(tiles, map.provider.limit, **kw)

def _tile_coords(map, coord, offset):
    """
    Create grid of coordinates of map tiles.

    :param map: Geotiler map instance.
    :param coord: Coordinate of top-left tile.
    :param offset: Map image offset of top-left tile.
    """
    w, h = map.size

    n = div_ceil(w - offset[0], map.provider.tile_width)
    m = div_ceil(h - offset[1], map.provider.tile_height)
    cols = range(coord[0], coord[0] + n)
    rows = range(coord[1], coord[1] + m)
    return itertools.product(cols, rows)


def _tile_offsets(map, offset):
    """
    Create grid of offsets of map tiles.

    :param map: Geotiler map instance.
    :param offset: Map image offset of top-left tile.
    """
    w, h = map.size
    cols = range(offset[0], w, map.provider.tile_width)
    rows = range(offset[1], h, map.provider.tile_height)
    return itertools.product(cols, rows)


def _find_top_left_tile(map):
    """
    Calculate the tile coordinate and its position relative to top-left
    offset of the map image.

    :param map: Map instance.
    """
    origin = map.origin
    w, h = map.size
    tw = map.provider.tile_width
    th = map.provider.tile_height

    px = map.offset[0] + w // 2
    py = map.offset[1] + h // 2

    dx = px // tw + (px % tw > 0)
    dy = py // th + (py % th > 0)

    px -= dx * tw
    py -= dy * th
    offset = px, py

    coord = origin[0] - dx, origin[1] - dy

    # if offset position not in ((-tw, -th), (0, 0)], then map image does
    # not contain the map properly
    assert -tw < offset[0] <= 0 and -th < offset[1] <= 0, offset

    return coord, offset


def calculateMapCenter(provider, tile_coord):
    """ Based on a provider and center coordinate, returns the coordinate
        of an initial tile and its point placement, relative to the map center.
    """
    # initial tile coordinate
    initTileCoord = math.floor(tile_coord[0]), math.floor(tile_coord[1])

    # initial tile position, assuming centered tile well in grid
    initX = (initTileCoord[0] - tile_coord[0]) * provider.tile_width
    initY = (initTileCoord[1] - tile_coord[1]) * provider.tile_height
    initPoint = round(initX), round(initY)

    return initTileCoord, initPoint

def calculateMapExtent(provider, width, height, *args):
    """ Based on a provider, width & height values, and a list of locations,
        returns the coordinate of an initial tile and its point placement,
        relative to the map center.
    """
    projection = provider.projection
    coordinates = [projection.rev_geocode(p) for p in args]

    TL = min(c[0] for c in coordinates), min(c[1] for c in coordinates)
    BR = max(c[0] for c in coordinates), max(c[1] for c in coordinates)

    # multiplication factor between horizontal span and map width
    hFactor = (BR[0] - TL[0]) / (width / provider.tile_width)

    # multiplication factor expressed as base-2 logarithm, for zoom difference
    hZoomDiff = math.log(hFactor) / math.log(2)

    # possible horizontal zoom to fit geographical extent in map width
    hPossibleZoom = projection.zoom - math.ceil(hZoomDiff)

    # multiplication factor between vertical span and map height
    vFactor = (BR[1] - TL[1]) / (height / provider.tile_height)

    # multiplication factor expressed as base-2 logarithm, for zoom difference
    vZoomDiff = math.log(vFactor) / math.log(2)

    # possible vertical zoom to fit geographical extent in map height
    vPossibleZoom = projection.zoom - math.ceil(vZoomDiff)

    # initial zoom to fit extent vertically and horizontally
    initZoom = min(hPossibleZoom, vPossibleZoom)

    ## additionally, make sure it's not outside the boundaries set by provider limits
    #initZoom = min(initZoom, provider.outerLimits()[1].zoom)
    #initZoom = max(initZoom, provider.outerLimits()[0].zoom)

    # coordinate of extent center
    centerColumn = (TL[0] + BR[0]) / 2
    centerRow = (TL[1] + BR[1]) / 2
    centerZoom = projection.zoom

    coord = zoom_to((centerColumn, centerRow), centerZoom, initZoom)

    origin, offset = calculateMapCenter(provider, coord)
    return origin, offset, initZoom


# vim: sw=4:et:ai
