from urllib.parse import urlencode, urlparse

import pyproj  # TODO: import issues

from large_image.cache_util import CacheProperties, methodcache
from large_image.constants import SourcePriority, TileInputUnits
from large_image.exceptions import TileSourceError

from .base import FileTileSource
from .utilities import JSONDict, getPaletteColors

# Inform the tile source cache about the potential size of this tile source
CacheProperties['tilesource']['itemExpectedSize'] = max(
    CacheProperties['tilesource']['itemExpectedSize'],
    100 * 1024 ** 2)

# Used to cache pixel size for projections
ProjUnitsAcrossLevel0 = {}
ProjUnitsAcrossLevel0_MaxSize = 100

InitPrefix = '+init='
NeededInitPrefix = '' if int(pyproj.proj_version_str.split('.')[0]) >= 6 else InitPrefix


def make_vsi(url: str, **options):
    if str(url).startswith('s3://'):
        s3_path = url.replace('s3://', '')
        vsi = f'/vsis3/{s3_path}'
    else:
        gdal_options = {
            'url': str(url),
            'use_head': 'no',
            'list_dir': 'no',
        }
        gdal_options.update(options)
        vsi = f'/vsicurl?{urlencode(gdal_options)}'
    return vsi


class GeoBaseFileTileSource(FileTileSource):
    """Abstract base class for geospatial tile sources."""

    _geospatial_source = True


class GDALBaseFileTileSource(GeoBaseFileTileSource):
    """Abstract base class for GDAL-based tile sources.

    This base class assumes the underlying library is powered by GDAL
    (rasterio, mapnik, etc.)
    """

    extensions = {
        None: SourcePriority.MEDIUM,
        'geotiff': SourcePriority.PREFERRED,
        # National Imagery Transmission Format
        'ntf': SourcePriority.PREFERRED,
        'nitf': SourcePriority.PREFERRED,
        'tif': SourcePriority.LOW,
        'tiff': SourcePriority.LOW,
        'vrt': SourcePriority.PREFERRED,
    }
    mimeTypes = {
        None: SourcePriority.FALLBACK,
        'image/geotiff': SourcePriority.PREFERRED,
        'image/tiff': SourcePriority.LOW,
        'image/x-tiff': SourcePriority.LOW,
    }

    def _getDriver(self):
        """
        Get the GDAL driver used to read this dataset.

        :returns: The name of the driver.
        """
        raise NotImplementedError

    def _convertProjectionUnits(self, *args, **kwargs):
        raise NotImplementedError

    def pixelToProjection(self, *args, **kwargs):
        raise NotImplementedError

    def toNativePixelCoordinates(self, *args, **kwargs):
        raise NotImplementedError

    def getBounds(self, *args, **kwargs):
        raise NotImplementedError

    def isGeospatial(path):
        """
        Check if a path is likely to be a geospatial file.

        :param path: The path to the file
        :returns: True if geospatial.
        """
        raise NotImplementedError

    def _getLargeImagePath(self):
        """Get GDAL-compatible image path.

        This will cast the output to a string and can also handle URLs
        ('http', 'https', 'ftp', 's3') for use with GDAL
        `Virtual Filesystems Interface <https://gdal.org/user/virtual_file_systems.html>`_.
        """
        if urlparse(str(self.largeImagePath)).scheme in {'http', 'https', 'ftp', 's3'}:
            return make_vsi(self.largeImagePath)
        return str(self.largeImagePath)

    def _setStyle(self, style):
        """
        Check and set the specified style from a json string or a dictionary.

        :param style: The new style.
        """
        super()._setStyle(style)
        if hasattr(self, '_getTileLock'):
            self._setDefaultStyle()

    def _styleBands(self):
        interpColorTable = {
            'red': ['#000000', '#ff0000'],
            'green': ['#000000', '#00ff00'],
            'blue': ['#000000', '#0000ff'],
            'gray': ['#000000', '#ffffff'],
            'alpha': ['#ffffff00', '#ffffffff'],
        }
        style = []
        if hasattr(self, '_style'):
            styleBands = self.style['bands'] if 'bands' in self.style else [self.style]
            for styleBand in styleBands:

                styleBand = styleBand.copy()
                # Default to band 1 -- perhaps we should default to gray or
                # green instead.
                styleBand['band'] = self._bandNumber(styleBand.get('band', 1))
                style.append(styleBand)
        if not len(style):
            for interp in ('red', 'green', 'blue', 'gray', 'palette', 'alpha'):
                band = self._bandNumber(interp, False)
                # If we don't have the requested band, or we only have alpha,
                # or this is gray or palette and we already added another band,
                # skip this interpretation.
                if (band is None or
                        (interp == 'alpha' and not len(style)) or
                        (interp in ('gray', 'palette') and len(style))):
                    continue
                if interp == 'palette':
                    bandInfo = self.getOneBandInformation(band)
                    style.append({
                        'band': band,
                        'palette': 'colortable',
                        'min': 0,
                        'max': len(bandInfo['colortable']) - 1})
                else:
                    style.append({
                        'band': band,
                        'palette': interpColorTable[interp],
                        'min': 'auto',
                        'max': 'auto',
                        'nodata': 'auto',
                        'composite': 'multiply' if interp == 'alpha' else 'lighten'
                    })
        return style

    def _setDefaultStyle(self):
        """If not style was specified, create a default style."""
        if hasattr(self, '_style'):
            styleBands = self.style['bands'] if 'bands' in self.style else [self.style]
            if not len(styleBands) or (len(styleBands) == 1 and isinstance(
                    styleBands[0].get('band', 1), int) and styleBands[0].get('band', 1) <= 0):
                del self._style
        style = self._styleBands()
        if len(style):
            hasAlpha = False
            for bstyle in style:
                hasAlpha = hasAlpha or self.getOneBandInformation(
                    bstyle.get('band', 0)).get('interpretation') == 'alpha'
                if 'palette' in bstyle:
                    if bstyle['palette'] == 'colortable':
                        bandInfo = self.getOneBandInformation(bstyle.get('band', 0))
                        bstyle['palette'] = [(
                            '#%02X%02X%02X' if len(entry) == 3 else
                            '#%02X%02X%02X%02X') % entry for entry in bandInfo['colortable']]
                    else:
                        bstyle['palette'] = self.getHexColors(bstyle['palette'])
                if bstyle.get('nodata') == 'auto':
                    bandInfo = self.getOneBandInformation(bstyle.get('band', 0))
                    bstyle['nodata'] = bandInfo.get('nodata', None)
            if not hasAlpha and self.projection:
                style.append({
                    'band': (
                        self._bandNumber('alpha', False)
                        if self._bandNumber('alpha', False) is not None else
                        (len(self.getBandInformation()) + 1)),
                    'min': 0,
                    'max': 'auto',
                    'composite': 'multiply',
                    'palette': ['#ffffff00', '#ffffffff'],
                })
            self.logger.debug('Using style %r', style)
            self._style = JSONDict({'bands': style})
        self._bandNames = {}
        for idx, band in self.getBandInformation().items():
            if band.get('interpretation'):
                self._bandNames[band['interpretation'].lower()] = idx

    @staticmethod
    def getHexColors(palette):
        """
        Returns list of hex colors for a given color palette

        :returns: List of colors
        """
        palette = getPaletteColors(palette)
        return ['#%02X%02X%02X%02X' % tuple(int(val) for val in clr) for clr in palette]

    def getPixelSizeInMeters(self):
        """
        Get the approximate base pixel size in meters.  This is calculated as
        the average scale of the four edges in the WGS84 ellipsoid.

        :returns: the pixel size in meters or None.
        """
        bounds = self.getBounds(NeededInitPrefix + 'epsg:4326')
        if not bounds:
            return
        geod = pyproj.Geod(ellps='WGS84')
        az12, az21, s1 = geod.inv(bounds['ul']['x'], bounds['ul']['y'],
                                  bounds['ur']['x'], bounds['ur']['y'])
        az12, az21, s2 = geod.inv(bounds['ur']['x'], bounds['ur']['y'],
                                  bounds['lr']['x'], bounds['lr']['y'])
        az12, az21, s3 = geod.inv(bounds['lr']['x'], bounds['lr']['y'],
                                  bounds['ll']['x'], bounds['ll']['y'])
        az12, az21, s4 = geod.inv(bounds['ll']['x'], bounds['ll']['y'],
                                  bounds['ul']['x'], bounds['ul']['y'])
        return (s1 + s2 + s3 + s4) / (self.sourceSizeX * 2 + self.sourceSizeY * 2)

    def getNativeMagnification(self):
        """
        Get the magnification at the base level.

        :return: width of a pixel in mm, height of a pixel in mm.
        """
        scale = self.getPixelSizeInMeters()
        return {
            'magnification': None,
            'mm_x': scale * 100 if scale else None,
            'mm_y': scale * 100 if scale else None,
        }

    def getTileCorners(self, z, x, y):
        """
        Returns bounds of a tile for a given x,y,z index.

        :param z: tile level
        :param x: tile offset from left.
        :param y: tile offset from right
        :returns: (xmin, ymin, xmax, ymax) in the current projection or base
            pixels.
        """
        x, y = float(x), float(y)
        if self.projection:
            # Scale tile into the range [-0.5, 0.5], [-0.5, 0.5]
            xmin = -0.5 + x / 2.0 ** z
            xmax = -0.5 + (x + 1) / 2.0 ** z
            ymin = 0.5 - (y + 1) / 2.0 ** z
            ymax = 0.5 - y / 2.0 ** z
            # Convert to projection coordinates
            xmin = self.projectionOrigin[0] + xmin * self.unitsAcrossLevel0
            xmax = self.projectionOrigin[0] + xmax * self.unitsAcrossLevel0
            ymin = self.projectionOrigin[1] + ymin * self.unitsAcrossLevel0
            ymax = self.projectionOrigin[1] + ymax * self.unitsAcrossLevel0
        else:
            xmin = 2 ** (self.sourceLevels - 1 - z) * x * self.tileWidth
            ymin = 2 ** (self.sourceLevels - 1 - z) * y * self.tileHeight
            xmax = xmin + 2 ** (self.sourceLevels - 1 - z) * self.tileWidth
            ymax = ymin + 2 ** (self.sourceLevels - 1 - z) * self.tileHeight
            ymin, ymax = self.sourceSizeY - ymax, self.sourceSizeY - ymin
        return xmin, ymin, xmax, ymax

    def _bandNumber(self, band, exc=True):
        """Given a band number or interpretation name, return a validated band number.

        :param band: either -1, a positive integer, or the name of a band interpretation
            that is present in the tile source.
        :param exc: if True, raise an exception if no band matches.

        :returns: a validated band, either 1 or a positive integer, or None if no
            matching band and exceptions are not enabled.
        """
        # retrieve the bands information from the initial dataset or cache
        bands = self.getBandInformation()

        # search for the band with multiple methods
        if isinstance(band, str) and str(band).isdigit():
            band = int(band)
        elif isinstance(band, str):
            band = next((i for i in bands if band == bands[i]['interpretation']), None)

        # set to None if not included in the possible band values
        isBandNumber = band == -1 or band in bands
        band = band if isBandNumber else None

        # raise an error if the band is not inside the dataset only if
        # requested from the function call
        if exc is True and band is None:
            raise TileSourceError(
                'Band has to be a positive integer, -1, or a band '
                'interpretation found in the source.'
            )

        return band

    def _getRegionBounds(self, metadata, left=None, top=None, right=None,
                         bottom=None, width=None, height=None, units=None,
                         **kwargs):
        """
        Given a set of arguments that can include left, right, top, bottom,
        width, height, and units, generate actual pixel values for left, top,
        right, and bottom.  If units is `'projection'`, use the source's
        projection.  If units starts with `'proj4:'` or `'epsg:'` or a
        custom units value, use that projection.  Otherwise, just use the super
        function.

        :param metadata: the metadata associated with this source.
        :param left: the left edge (inclusive) of the region to process.
        :param top: the top edge (inclusive) of the region to process.
        :param right: the right edge (exclusive) of the region to process.
        :param bottom: the bottom edge (exclusive) of the region to process.
        :param width: the width of the region to process.  Ignored if both
            left and right are specified.
        :param height: the height of the region to process.  Ignores if both
            top and bottom are specified.
        :param units: either 'projection', a string starting with 'proj4:',
            'epsg:' or a enumerated value like 'wgs84', or one of the super's
            values.
        :param kwargs: optional parameters.  See above.
        :returns: left, top, right, bottom bounds in pixels.
        """
        units = TileInputUnits.get(units.lower() if units else units, units)
        # If a proj4 projection is specified, convert the left, right, top, and
        # bottom to the current projection or to pixel space if no projection
        # is used.
        if (units and (units.lower().startswith('proj4:') or
                       units.lower().startswith('epsg:') or
                       units.lower().startswith('+proj='))):
            left, top, right, bottom, units = self._convertProjectionUnits(
                left, top, right, bottom, width, height, units, **kwargs)

        if units == 'projection' and self.projection:
            bounds = self.getBounds(self.projection)
            # Fill in missing values
            if left is None:
                left = bounds['xmin'] if right is None or width is None else right - width
            if right is None:
                right = bounds['xmax'] if width is None else left + width
            if top is None:
                top = bounds['ymax'] if bottom is None or width is None else bottom - width
            if bottom is None:
                bottom = bounds['ymin'] if width is None else top + width
            if not kwargs.get('unitsWH') or kwargs.get('unitsWH') == units:
                width = height = None
            # Convert to [-0.5, 0.5], [-0.5, 0.5] coordinate range
            left = (left - self.projectionOrigin[0]) / self.unitsAcrossLevel0
            right = (right - self.projectionOrigin[0]) / self.unitsAcrossLevel0
            top = (top - self.projectionOrigin[1]) / self.unitsAcrossLevel0
            bottom = (bottom - self.projectionOrigin[1]) / self.unitsAcrossLevel0
            # Convert to world=wide 'base pixels' and crop to the world
            xScale = 2 ** (self.levels - 1) * self.tileWidth
            yScale = 2 ** (self.levels - 1) * self.tileHeight
            left = max(0, min(xScale, (0.5 + left) * xScale))
            right = max(0, min(xScale, (0.5 + right) * xScale))
            top = max(0, min(yScale, (0.5 - top) * yScale))
            bottom = max(0, min(yScale, (0.5 - bottom) * yScale))
            # Ensure correct ordering
            left, right = min(left, right), max(left, right)
            top, bottom = min(top, bottom), max(top, bottom)
            units = 'base_pixels'
        return super()._getRegionBounds(
            metadata, left, top, right, bottom, width, height, units, **kwargs)

    @methodcache()
    def getThumbnail(self, width=None, height=None, **kwargs):
        """
        Get a basic thumbnail from the current tile source.  Aspect ratio is
        preserved.  If neither width nor height is given, a default value is
        used.  If both are given, the thumbnail will be no larger than either
        size.  A thumbnail has the same options as a region except that it
        always includes the entire image if there is no projection and has a
        default size of 256 x 256.

        :param width: maximum width in pixels.
        :param height: maximum height in pixels.
        :param kwargs: optional arguments.  Some options are encoding,
            jpegQuality, jpegSubsampling, and tiffCompression.
        :returns: thumbData, thumbMime: the image data and the mime type.
        """
        if self.projection:
            if ((width is not None and width < 2) or
                    (height is not None and height < 2)):
                raise ValueError('Invalid width or height.  Minimum value is 2.')
            if width is None and height is None:
                width = height = 256
            params = dict(kwargs)
            params['output'] = {'maxWidth': width, 'maxHeight': height}
            params['region'] = {'units': 'projection'}
            return self.getRegion(**params)
        return super().getThumbnail(width, height, **kwargs)
