# -:- encoding: utf-8 -:-
# This file is part of the MapProxy project.
# Copyright (C) 2010 Omniscale <http://omniscale.de>
#
# 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.

"""
Layers that can get maps/infos from different sources/caches.
"""

from __future__ import division
from mapproxy.grid import NoTiles, GridError, merge_resolution_range, bbox_intersects, bbox_contains
from mapproxy.image import SubImageSource, bbox_position_in_image
from mapproxy.image.opts import ImageOptions
from mapproxy.image.tile import TiledImage
from mapproxy.srs import SRS, bbox_equals, merge_bbox, make_lin_transf, SupportedSRS
from mapproxy.proj import ProjError

import logging
from functools import reduce
log = logging.getLogger(__name__)


class BlankImage(Exception):
    pass


class MapError(Exception):
    pass


class MapBBOXError(Exception):
    pass


class MapLayer(object):
    supports_meta_tiles = False

    res_range = None

    coverage = None

    def __init__(self, image_opts=None):
        self.image_opts = image_opts or ImageOptions()

    def _get_opacity(self):
        return self.image_opts.opacity

    def _set_opacity(self, value):
        self.image_opts.opacity = value

    opacity = property(_get_opacity, _set_opacity)

    def is_opaque(self, query):
        """
        Whether the query result is opaque.

        This method is used for optimizations: layers below an opaque
        layer can be skipped. As sources with `transparent: false`
        still can return transparent images (min_res/max_res/coverages),
        implementations of this method need to be certain that the image
        is indeed opaque. is_opaque should return False if in doubt.
        """
        return False

    def check_res_range(self, query):
        if (self.res_range and
                not self.res_range.contains(query.bbox, query.size, query.srs)):
            raise BlankImage()

    def get_map(self, query):
        raise NotImplementedError

    def combined_layer(self, other, query):
        return None


class LimitedLayer(object):
    """
    Wraps an existing layer temporary and stores additional
    attributes for geographical limits.
    """

    def __init__(self, layer, coverage):
        self._layer = layer
        self.coverage = coverage

    def __getattr__(self, name):
        return getattr(self._layer, name)

    def combined_layer(self, other, query):
        if self.coverage == other.coverage:
            combined = self._layer.combined_layer(other, query)
            if combined:
                return LimitedLayer(combined, self.coverage)
        return None

    def get_info(self, query):
        if self.coverage:
            if not self.coverage.contains(query.coord, query.srs):
                return None
        return self._layer.get_info(query)


class InfoLayer(object):
    def get_info(self, query):
        raise NotImplementedError


class MapQuery(object):
    """
    Internal query for a map with a specific extent, size, srs, etc.
    """

    def __init__(self, bbox, size, srs, format='image/png', transparent=False,
                 tiled_only=False, dimensions=None):
        self.bbox = bbox
        self.size = size
        self.srs = srs
        self.format = format
        self.transparent = transparent
        self.tiled_only = tiled_only
        self.dimensions = dimensions or {}

    def dimensions_for_params(self, params):
        """
        Return subset of the dimensions.

        >>> mq = MapQuery(None, None, None, dimensions={'Foo': 1, 'bar': 2})
        >>> mq.dimensions_for_params(set(['FOO', 'baz']))
        {'Foo': 1}
        """
        params = [p.lower() for p in params]
        return dict((k, v) for k, v in self.dimensions.items() if k.lower() in params)

    def __repr__(self):
        info = self.__dict__
        serialized_dimensions = ", ".join(["'%s': '%s'" % (key, value) for (key, value) in self.dimensions.items()])
        info["serialized_dimensions"] = serialized_dimensions
        return ("MapQuery(bbox=%(bbox)s, size=%(size)s, srs=%(srs)r, format=%(format)s,"
                " dimensions={%(serialized_dimensions)s)}") % info


class InfoQuery(object):
    def __init__(self, bbox, size, srs, pos, info_format, format=None,
                 feature_count=None):
        self.bbox = bbox
        self.size = size
        self.srs = srs
        self.pos = pos
        self.info_format = info_format
        self.format = format
        self.feature_count = feature_count

    @property
    def coord(self):
        return make_lin_transf((0, 0, self.size[0], self.size[1]), self.bbox)(self.pos)


class LegendQuery(object):
    def __init__(self, format, scale):
        self.format = format
        self.scale = scale


class Dimension(list):
    def __init__(self, identifier, values, default=None):
        self.identifier = identifier
        if not default and values:
            default = values[0]
        self.default = default
        list.__init__(self, values)


def map_extent_from_grid(grid):
    """
    >>> from mapproxy.grid import tile_grid_for_epsg
    >>> map_extent_from_grid(tile_grid_for_epsg('EPSG:900913'))
    ... #doctest: +NORMALIZE_WHITESPACE
    MapExtent((-20037508.342789244, -20037508.342789244,
               20037508.342789244, 20037508.342789244), SRS('EPSG:900913'))
    """
    return MapExtent(grid.bbox, grid.srs)


class MapExtent(object):
    """
    >>> me = MapExtent((5, 45, 15, 55), SRS(4326))
    >>> me.llbbox
    (5, 45, 15, 55)
    >>> [int(x) for x in me.bbox_for(SRS(900913))]
    [556597, 5621521, 1669792, 7361866]
    >>> [int(x) for x in me.bbox_for(SRS(4326))]
    [5, 45, 15, 55]
    """
    is_default = False

    def __init__(self, bbox, srs):
        self._llbbox = None
        self.bbox = bbox
        self.srs = srs

    @property
    def llbbox(self):
        if not self._llbbox:
            self._llbbox = self.srs.transform_bbox_to(self.srs.get_geographic_srs(), self.bbox)
        return self._llbbox

    def bbox_for(self, srs):
        if srs == self.srs:
            return self.bbox

        return self.srs.transform_bbox_to(srs, self.bbox)

    def __repr__(self):
        return "%s(%r, %r)" % (self.__class__.__name__, self.bbox, self.srs)

    def __eq__(self, other):
        if not isinstance(other, MapExtent):
            return NotImplemented

        if self.srs != other.srs:
            return False

        if self.bbox != other.bbox:
            return False

        return True

    def __ne__(self, other):
        if not isinstance(other, MapExtent):
            raise NotImplementedError
        return not self.__eq__(other)

    def __add__(self, other):
        if not isinstance(other, MapExtent):
            raise NotImplementedError
        if other.is_default:
            return self
        if self.is_default:
            return other
        return MapExtent(merge_bbox(self.llbbox, other.llbbox), self.srs.get_geographic_srs())

    def contains(self, other):
        if not isinstance(other, MapExtent):
            raise NotImplementedError
        if self.is_default:
            # DefaultMapExtent contains everything
            return True
        return bbox_contains(self.bbox, other.bbox_for(self.srs))

    def intersects(self, other):
        if not isinstance(other, MapExtent):
            raise NotImplementedError
        return bbox_intersects(self.bbox, other.bbox_for(self.srs))

    def intersection(self, other):
        """
        Returns the intersection of `self` and `other`.

        >>> e = DefaultMapExtent().intersection(MapExtent((0, 0, 10, 10), SRS(4326)))
        >>> e.bbox, e.srs
        ((0, 0, 10, 10), SRS('EPSG:4326'))
        """
        if not self.intersects(other):
            return None

        source = self.bbox
        sub = other.bbox_for(self.srs)

        return MapExtent((
            max(source[0], sub[0]),
            max(source[1], sub[1]),
            min(source[2], sub[2]),
            min(source[3], sub[3])),
            self.srs)

    def transform(self, srs):
        return MapExtent(self.bbox_for(srs), srs)


class DefaultMapExtent(MapExtent):
    """
    Default extent that covers the whole world.
    Will not affect other extents when added.

    >>> m1 = MapExtent((0, 0, 10, 10), SRS(4326))
    >>> m2 = MapExtent((10, 0, 20, 10), SRS(4326))
    >>> m3 = DefaultMapExtent()
    >>> (m1 + m2).bbox
    (0, 0, 20, 10)
    >>> (m1 + m3).bbox
    (0, 0, 10, 10)
    """
    is_default = True

    def __init__(self):
        MapExtent.__init__(self, (-180, -90, 180, 90), SRS(4326))


def merge_layer_extents(layers):
    if not layers:
        return DefaultMapExtent()
    layers = layers[:]
    extent = layers.pop().extent
    for layer in layers:
        extent = extent + layer.extent
    return extent


class ResolutionConditional(MapLayer):
    supports_meta_tiles = True

    def __init__(self, one, two, resolution, srs, extent, opacity=None):
        MapLayer.__init__(self)
        self.one = one
        self.two = two
        self.res_range = merge_layer_res_ranges([one, two])
        self.resolution = resolution
        self.srs = srs

        self.opacity = opacity
        self.extent = extent

    def get_map(self, query):
        self.check_res_range(query)
        bbox = query.bbox
        if query.srs != self.srs:
            bbox = query.srs.transform_bbox_to(self.srs, bbox)

        xres = (bbox[2] - bbox[0]) / query.size[0]
        yres = (bbox[3] - bbox[1]) / query.size[1]
        res = min(xres, yres)
        log.debug('actual res: %s, threshold res: %s', res, self.resolution)

        if res > self.resolution:
            return self.one.get_map(query)
        else:
            return self.two.get_map(query)


class SRSConditional(MapLayer):
    supports_meta_tiles = True

    def __init__(self, layers, extent, opacity=None, preferred_srs=None):
        MapLayer.__init__(self)
        self.srs_map = {}
        self.res_range = merge_layer_res_ranges([x[0] for x in layers])

        supported_srs = []
        for layer, srs in layers:
            supported_srs.append(srs)
            self.srs_map[srs] = layer
        self.supported_srs = SupportedSRS(supported_srs, preferred_srs)
        self.extent = extent
        self.opacity = opacity

    def get_map(self, query):
        self.check_res_range(query)
        layer = self._select_layer(query.srs)
        return layer.get_map(query)

    def _select_layer(self, query_srs):
        srs = self.supported_srs.best_srs(query_srs)
        return self.srs_map[srs]


class DirectMapLayer(MapLayer):
    supports_meta_tiles = True

    def __init__(self, source, extent):
        MapLayer.__init__(self)
        self.source = source
        self.res_range = getattr(source, 'res_range', None)
        self.extent = extent

    def get_map(self, query):
        self.check_res_range(query)
        return self.source.get_map(query)


def merge_layer_res_ranges(layers):
    ranges = [s.res_range for s in layers
              if hasattr(s, 'res_range')]

    if ranges:
        ranges = reduce(merge_resolution_range, ranges)

    return ranges


class CacheMapLayer(MapLayer):
    supports_meta_tiles = True

    def __init__(self, tile_manager, extent=None, image_opts=None,
                 max_tile_limit=None):
        MapLayer.__init__(self, image_opts=image_opts)
        self.tile_manager = tile_manager
        self.grid = tile_manager.grid
        self.extent = extent or map_extent_from_grid(self.grid)
        self.res_range = []
        if not self.tile_manager.rescale_tiles:
            self.res_range = merge_layer_res_ranges(self.tile_manager.sources)
        self.max_tile_limit = max_tile_limit

    def get_map(self, query):
        self.check_res_range(query)

        if query.tiled_only:
            self._check_tiled(query)

        query_extent = MapExtent(query.bbox, query.srs)
        if not query.tiled_only and self.extent and not self.extent.contains(query_extent):
            if not self.extent.intersects(query_extent):
                raise BlankImage()
            size, offset, bbox = bbox_position_in_image(query.bbox, query.size, self.extent.bbox_for(query.srs))
            if size[0] == 0 or size[1] == 0:
                raise BlankImage()
            src_query = MapQuery(bbox, size, query.srs, query.format, dimensions=query.dimensions)
            resp = self._image(src_query)
            result = SubImageSource(resp, size=query.size, offset=offset, image_opts=self.image_opts,
                                    cacheable=resp.cacheable)
        else:
            result = self._image(query)
        return result

    def _check_tiled(self, query):
        if query.format != self.tile_manager.format:
            raise MapError("invalid tile format, use %s" % self.tile_manager.format)
        if query.size != self.grid.tile_size:
            raise MapError("invalid tile size (use %dx%d)" % self.grid.tile_size)

    def _image(self, query):
        try:
            src_bbox, tile_grid, affected_tile_coords = \
                self.grid.get_affected_tiles(query.bbox, query.size,
                                             req_srs=query.srs)
        except NoTiles:
            raise BlankImage()
        except GridError as ex:
            raise MapBBOXError(ex.args[0])

        num_tiles = tile_grid[0] * tile_grid[1]

        if self.max_tile_limit and num_tiles >= self.max_tile_limit:
            raise MapBBOXError("too many tiles, max_tile_limit: %s, num_tiles: %s" % (self.max_tile_limit, num_tiles))

        if query.tiled_only:
            if num_tiles > 1:
                raise MapBBOXError("not a single tile")
            bbox = query.bbox
            if not bbox_equals(bbox, src_bbox, abs((bbox[2]-bbox[0])/query.size[0]/10),
                               abs((bbox[3]-bbox[1])/query.size[1]/10)):
                raise MapBBOXError("query does not align to tile boundaries")

        with self.tile_manager.session():
            tile_collection = self.tile_manager.load_tile_coords(
                affected_tile_coords, with_metadata=query.tiled_only, dimensions=query.dimensions)

        if tile_collection.empty:
            raise BlankImage()

        if query.tiled_only:
            tile = tile_collection[0].source
            tile.image_opts = self.tile_manager.image_opts
            tile.cacheable = tile_collection[0].cacheable
            return tile

        tile_sources = [tile.source for tile in tile_collection]
        tiled_image = TiledImage(tile_sources, src_bbox=src_bbox, src_srs=self.grid.srs,
                                 tile_grid=tile_grid, tile_size=self.grid.tile_size)
        try:
            return tiled_image.transform(query.bbox, query.srs, query.size,
                                         self.tile_manager.image_opts)
        except ProjError:
            raise MapBBOXError("could not transform query BBOX")
        except IOError as ex:
            from mapproxy.source import SourceError
            raise SourceError("unable to transform image: %s" % ex)
