# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import binascii
import io
from typing import Tuple, Union

from PIL import Image, ImageOps
# We can preload Ico too because it is considered safe
from PIL import IcoImagePlugin
try:
    from PIL.Image import Transpose, Palette, Resampling
except ImportError:
    Transpose = Palette = Resampling = Image

from random import randrange

from odoo.exceptions import UserError
from odoo.tools.misc import DotDict
from odoo.tools.translate import LazyTranslate


__all__ = ["image_process"]
_lt = LazyTranslate('base')

# Preload PIL with the minimal subset of image formats we need
Image.preinit()
Image._initialized = 2

# Maps only the 6 first bits of the base64 data, accurate enough
# for our purpose and faster than decoding the full blob first
FILETYPE_BASE64_MAGICWORD = {
    b'/': 'jpg',
    b'R': 'gif',
    b'i': 'png',
    b'P': 'svg+xml',
    b'U': 'webp',
}

EXIF_TAG_ORIENTATION = 0x112
# The target is to have 1st row/col to be top/left
# Note: rotate is counterclockwise
EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col:
    0: [],                                              # reserved
    1: [],                                              # top/left
    2: [Transpose.FLIP_LEFT_RIGHT],                     # top/right
    3: [Transpose.ROTATE_180],                          # bottom/right
    4: [Transpose.FLIP_TOP_BOTTOM],                     # bottom/left
    5: [Transpose.FLIP_LEFT_RIGHT, Transpose.ROTATE_90],# left/top
    6: [Transpose.ROTATE_270],                          # right/top
    7: [Transpose.FLIP_TOP_BOTTOM, Transpose.ROTATE_90],# right/bottom
    8: [Transpose.ROTATE_90],                           # left/bottom
}

# Arbitrary limit to fit most resolutions, including Samsung Galaxy A22 photo,
# 8K with a ratio up to 16:10, and almost all variants of 4320p
IMAGE_MAX_RESOLUTION = 50e6


class ImageProcess:

    def __init__(self, source, verify_resolution=True):
        """Initialize the ``source`` image for processing.

        :param bytes source: the original image binary

            No processing will be done if the `source` is falsy or if
            the image is SVG.
        :param verify_resolution: if True, make sure the original image size is not
            excessive before starting to process it. The max allowed resolution is
            defined by `IMAGE_MAX_RESOLUTION`.
        :type verify_resolution: bool
        :rtype: ImageProcess

        :raise: ValueError if `verify_resolution` is True and the image is too large
        :raise: UserError if the image can't be identified by PIL
        """
        self.source = source or False
        self.operationsCount = 0

        if not source or source[:1] == b'<' or (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
            # don't process empty source or SVG or WEBP
            self.image = False
        else:
            try:
                self.image = Image.open(io.BytesIO(source))
            except (OSError, binascii.Error):
                raise UserError(_lt("This file could not be decoded as an image file."))

            # Original format has to be saved before fixing the orientation or
            # doing any other operations because the information will be lost on
            # the resulting image.
            self.original_format = (self.image.format or '').upper()

            self.image = image_fix_orientation(self.image)

            w, h = self.image.size
            if verify_resolution and w * h > IMAGE_MAX_RESOLUTION:
                raise UserError(_lt("Image size excessive, uploaded images must be smaller than %s million pixels.", str(IMAGE_MAX_RESOLUTION / 1e6)))

    def image_quality(self, quality=0, output_format=''):
        """Return the image resulting of all the image processing
        operations that have been applied previously.

        The source is returned as-is if it's an SVG, or if no operations have
        been applied, the `output_format` is the same as the original format,
        and the quality is not specified.

        :param int quality: quality setting to apply. Default to 0.

            - for JPEG: 1 is worse, 95 is best. Values above 95 should be
              avoided. Falsy values will fallback to 95, but only if the image
              was changed, otherwise the original image is returned.
            - for PNG: set falsy to prevent conversion to a WEB palette.
            - for other formats: no effect.

        :param str output_format: Can be PNG, JPEG, GIF, or ICO.
            Default to the format of the original image if a valid output format,
            otherwise BMP is converted to PNG and the rest are converted to JPEG.
        :return: the final image, or ``False`` if the original ``source`` was falsy.
        :rtype: bytes | False
        """
        if not self.image:
            return self.source

        output_image = self.image

        output_format = output_format.upper() or self.original_format
        if output_format == 'BMP':
            output_format = 'PNG'
        elif output_format not in ['PNG', 'JPEG', 'GIF', 'ICO']:
            output_format = 'JPEG'

        if not self.operationsCount and output_format == self.original_format and not quality:
            return self.source

        opt = {'output_format': output_format}

        if output_format == 'PNG':
            opt['optimize'] = True
            if quality:
                if output_image.mode != 'P':
                    # Floyd Steinberg dithering by default
                    output_image = output_image.convert('RGBA').convert('P', palette=Palette.WEB, colors=256)
        if output_format == 'JPEG':
            opt['optimize'] = True
            opt['quality'] = quality or 95
        if output_format == 'GIF':
            opt['optimize'] = True
            opt['save_all'] = True

        if output_image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (output_format == 'JPEG' and output_image.mode == 'RGBA'):
            output_image = output_image.convert("RGB")

        output_bytes = image_apply_opt(output_image, **opt)
        if len(output_bytes) >= len(self.source) and self.original_format == output_format and not self.operationsCount:
            # Format has not changed and image content is unchanged but the
            # reached binary is bigger: rather use the original.
            return self.source
        return output_bytes

    def resize(self, max_width=0, max_height=0, expand=False):
        """Resize the image.

        The image is not resized above the current image size, unless the expand
        parameter is True. This method is used by default to create smaller versions
        of the image.

        The current ratio is preserved. To change the ratio, see `crop_resize`.

        If `max_width` or `max_height` is falsy, it will be computed from the
        other to keep the current ratio. If both are falsy, no resize is done.

        It is currently not supported for GIF because we do not handle all the
        frames properly.

        :param int max_width: max width
        :param int max_height: max height
        :param bool expand: whether or not the image size can be increased
        :return: self to allow chaining
        :rtype: ImageProcess
        """
        if self.image and self.original_format != 'GIF' and (max_width or max_height):
            w, h = self.image.size
            asked_width = max_width or (w * max_height) // h
            asked_height = max_height or (h * max_width) // w
            if expand and (asked_width > w or asked_height > h):
                self.image = self.image.resize((asked_width, asked_height))
                self.operationsCount += 1
                return self
            if asked_width != w or asked_height != h:
                self.image.thumbnail((asked_width, asked_height), Resampling.LANCZOS)
                if self.image.width != w or self.image.height != h:
                    self.operationsCount += 1
        return self

    def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5):
        """Crop and resize the image.

        The image is never resized above the current image size. This method is
        only to create smaller versions of the image.

        Instead of preserving the ratio of the original image like `resize`,
        this method will force the output to take the ratio of the given
        `max_width` and `max_height`, so both have to be defined.

        The crop is done before the resize in order to preserve as much of the
        original image as possible. The goal of this method is primarily to
        resize to a given ratio, and it is not to crop unwanted parts of the
        original image. If the latter is what you want to do, you should create
        another method, or directly use the `crop` method from PIL.

        It is currently not supported for GIF because we do not handle all the
        frames properly.

        :param int max_width: max width
        :param int max_height: max height
        :param float center_x: the center of the crop between 0 (left) and 1
            (right). Defaults to 0.5 (center).
        :param float center_y: the center of the crop between 0 (top) and 1
            (bottom). Defaults to 0.5 (center).
        :return: self to allow chaining
        :rtype: ImageProcess
        """
        if self.image and self.original_format != 'GIF' and max_width and max_height:
            w, h = self.image.size
            # We want to keep as much of the image as possible -> at least one
            # of the 2 crop dimensions always has to be the same value as the
            # original image.
            # The target size will be reached with the final resize.
            if w / max_width > h / max_height:
                new_w, new_h = w, (max_height * w) // max_width
            else:
                new_w, new_h = (max_width * h) // max_height, h

            # No cropping above image size.
            if new_w > w:
                new_w, new_h = w, (new_h * w) // new_w
            if new_h > h:
                new_w, new_h = (new_w * h) // new_h, h

            # Dimensions should be at least 1.
            new_w, new_h = max(new_w, 1), max(new_h, 1)

            # Correctly place the center of the crop.
            x_offset = int((w - new_w) * center_x)
            h_offset = int((h - new_h) * center_y)

            if new_w != w or new_h != h:
                self.image = self.image.crop((x_offset, h_offset, x_offset + new_w, h_offset + new_h))
                if self.image.width != w or self.image.height != h:
                    self.operationsCount += 1

        return self.resize(max_width, max_height)

    def colorize(self, color=None):
        """Replace the transparent background by a given color, or by a random one.

        :param tuple color: RGB values for the color to use
        :return: self to allow chaining
        :rtype: ImageProcess
        """
        if color is None:
            color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
        if self.image:
            original = self.image
            self.image = Image.new('RGB', original.size)
            self.image.paste(color, box=(0, 0) + original.size)
            self.image.paste(original, mask=original)
            self.operationsCount += 1
        return self

    def add_padding(self, padding):
        """Expand the image size by adding padding around the image

        :param int padding: thickness of the padding
        :return: self to allow chaining
        :rtype: ImageProcess
        """
        if self.image:
            img_width, img_height = self.image.size
            self.image = self.image.resize((img_width - 2 * padding, img_height - 2 * padding))
            self.image = ImageOps.expand(self.image, border=padding)
            self.operationsCount += 1
        return self


def image_process(source, size=(0, 0), verify_resolution=False, quality=0, expand=False, crop=None, colorize=False, output_format='', padding=False):
    """Process the `source` image by executing the given operations and
    return the result image.
    """
    if not source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format and not padding):
        # for performance: don't do anything if the image is falsy or if
        # no operations have been requested
        return source

    image = ImageProcess(source, verify_resolution)
    if size:
        if crop:
            center_x = 0.5
            center_y = 0.5
            if crop == 'top':
                center_y = 0
            elif crop == 'bottom':
                center_y = 1
            image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y)
        else:
            image.resize(max_width=size[0], max_height=size[1], expand=expand)
    if padding:
        image.add_padding(padding)
    if colorize:
        image.colorize(colorize if isinstance(colorize, tuple) else None)
    return image.image_quality(quality=quality, output_format=output_format)


# ----------------------------------------
# Misc image tools
# ---------------------------------------

def average_dominant_color(colors, mitigate=175, max_margin=140):
    """This function is used to calculate the dominant colors when given a list of colors

    There are 5 steps:

    1) Select dominant colors (highest count), isolate its values and remove
       it from the current color set.
    2) Set margins according to the prevalence of the dominant color.
    3) Evaluate the colors. Similar colors are grouped in the dominant set
       while others are put in the "remaining" list.
    4) Calculate the average color for the dominant set. This is done by
       averaging each band and joining them into a tuple.
    5) Mitigate final average and convert it to hex

    :param colors: list of tuples having:

        0. color count in the image
        1. actual color: tuple(R, G, B, A)

        -> these can be extracted from a PIL image using
        :meth:`~PIL.Image.Image.getcolors`
    :param mitigate: maximum value a band can reach
    :param max_margin: maximum difference from one of the dominant values
    :returns: a tuple with two items:

        0. the average color of the dominant set as: tuple(R, G, B)
        1. list of remaining colors, used to evaluate subsequent dominant colors
    """
    dominant_color = max(colors)
    dominant_rgb = dominant_color[1][:3]
    dominant_set = [dominant_color]
    remaining = []

    margins = [max_margin * (1 - dominant_color[0] /
                             sum([col[0] for col in colors]))] * 3

    colors.remove(dominant_color)

    for color in colors:
        rgb = color[1]
        if (rgb[0] < dominant_rgb[0] + margins[0] and rgb[0] > dominant_rgb[0] - margins[0] and
            rgb[1] < dominant_rgb[1] + margins[1] and rgb[1] > dominant_rgb[1] - margins[1] and
                rgb[2] < dominant_rgb[2] + margins[2] and rgb[2] > dominant_rgb[2] - margins[2]):
            dominant_set.append(color)
        else:
            remaining.append(color)

    dominant_avg = []
    for band in range(3):
        avg = total = 0
        for color in dominant_set:
            avg += color[0] * color[1][band]
            total += color[0]
        dominant_avg.append(int(avg / total))

    final_dominant = []
    brightest = max(dominant_avg)
    for color in range(3):
        value = dominant_avg[color] / (brightest / mitigate) if brightest > mitigate else dominant_avg[color]
        final_dominant.append(int(value))

    return tuple(final_dominant), remaining


def image_fix_orientation(image):
    """Fix the orientation of the image if it has an EXIF orientation tag.

    This typically happens for images taken from a non-standard orientation
    by some phones or other devices that are able to report orientation.

    The specified transposition is applied to the image before all other
    operations, because all of them expect the image to be in its final
    orientation, which is the case only when the first row of pixels is the top
    of the image and the first column of pixels is the left of the image.

    Moreover the EXIF tags will not be kept when the image is later saved, so
    the transposition has to be done to ensure the final image is correctly
    orientated.

    Note: to be completely correct, the resulting image should have its exif
    orientation tag removed, since the transpositions have been applied.
    However since this tag is not used in the code, it is acceptable to
    save the complexity of removing it.

    :param image: the source image
    :type image: ~PIL.Image.Image
    :return: the resulting image, copy of the source, with orientation fixed
        or the source image if no operation was applied
    :rtype: ~PIL.Image.Image
    """
    getexif = getattr(image, 'getexif', None) or getattr(image, '_getexif', None)  # support PIL < 6.0
    if getexif:
        exif = getexif()
        if exif:
            orientation = exif.get(EXIF_TAG_ORIENTATION, 0)
            for method in EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS.get(orientation, []):
                image = image.transpose(method)
            return image
    return image


def binary_to_image(source):
    try:
        return Image.open(io.BytesIO(source))
    except (OSError, binascii.Error):
        raise UserError(_lt("This file could not be decoded as an image file."))

def base64_to_image(base64_source: Union[str, bytes]) -> Image:
    """Return a PIL image from the given `base64_source`.

    :param base64_source: the image base64 encoded
    :raise: UserError if the base64 is incorrect or the image can't be identified by PIL
    """
    try:
        return Image.open(io.BytesIO(base64.b64decode(base64_source)))
    except (OSError, binascii.Error):
        raise UserError(_lt("This file could not be decoded as an image file."))


def image_apply_opt(image: Image, output_format: str, **params) -> bytes:
    """Return the serialization of the provided `image` to `output_format`
    using `params`.

    :param image: the image to encode
    :param output_format: :meth:`~PIL.Image.Image.save`'s ``format`` parameter
    :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save`
    :return: the image formatted
    """
    if output_format == 'JPEG' and image.mode not in ['1', 'L', 'RGB']:
        image = image.convert("RGB")
    stream = io.BytesIO()
    image.save(stream, format=output_format, **params)
    return stream.getvalue()


def image_to_base64(image, output_format, **params):
    """Return a base64_image from the given PIL `image` using `params`.

    :type image: ~PIL.Image.Image
    :param str output_format:
    :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save`
    :return: the image base64 encoded
    :rtype: bytes
    """
    stream = image_apply_opt(image, output_format, **params)
    return base64.b64encode(stream)


def get_webp_size(source):
    """
    Returns the size of the provided webp binary source for VP8, VP8X and
    VP8L, otherwise returns None.
    See https://developers.google.com/speed/webp/docs/riff_container.

    :param source: binary source
    :return: (width, height) tuple, or None if not supported
    """
    if not (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
        raise UserError(_lt("This file is not a webp file."))

    vp8_type = source[15]
    if vp8_type == 0x20:  # 0x20 = ' '
        # Sizes on big-endian 16 bits at offset 26.
        width_low, width_high, height_low, height_high = source[26:30]
        width = (width_high << 8) + width_low
        height = (height_high << 8) + height_low
        return (width, height)
    elif vp8_type == 0x58:  # 0x48 = 'X'
        # Sizes (minus one) on big-endian 24 bits at offset 24.
        width_low, width_medium, width_high, height_low, height_medium, height_high = source[24:30]
        width = 1 + (width_high << 16) + (width_medium << 8) + width_low
        height = 1 + (height_high << 16) + (height_medium << 8) + height_low
        return (width, height)
    elif vp8_type == 0x4C and source[20] == 0x2F:  # 0x4C = 'L'
        # Sizes (minus one) on big-endian-ish 14 bits at offset 21.
        # E.g. [@20] 2F ab cd ef gh
        # - width = 1 + (c&0x3)d ab: ignore the two high bits of the second byte
        # - height= 1 + hef(c&0xC>>2): used them as the first two bits of the height
        ab, cd, ef, gh = source[21:25]
        width = 1 + ((cd & 0x3F) << 8) + ab
        height = 1 + ((gh & 0xF) << 10) + (ef << 2) + (cd >> 6)
        return (width, height)
    return None


def is_image_size_above(base64_source_1, base64_source_2):
    """Return whether or not the size of the given image `base64_source_1` is
    above the size of the given image `base64_source_2`.
    """
    if not base64_source_1 or not base64_source_2:
        return False
    if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'):
        # False for SVG
        return False

    def get_image_size(base64_source):
        source = base64.b64decode(base64_source)
        if (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
            size = get_webp_size(source)
            if size:
                return DotDict({'width': size[0], 'height': size[0]})
            else:
                # False for unknown WEBP format
                return False
        else:
            return image_fix_orientation(binary_to_image(source))

    image_source = get_image_size(base64_source_1)
    image_target = get_image_size(base64_source_2)
    return image_source.width > image_target.width or image_source.height > image_target.height


def image_guess_size_from_field_name(field_name: str) -> Tuple[int, int]:
    """Attempt to guess the image size based on `field_name`.

    If it can't be guessed or if it is a custom field: return (0, 0) instead.

    :param field_name: the name of a field
    :return: the guessed size
    """
    if field_name == 'image':
        return (1024, 1024)
    if field_name.startswith('x_'):
        return (0, 0)
    try:
        suffix = int(field_name.rsplit('_', 1)[-1])
    except ValueError:
        return 0, 0

    if suffix < 16:
        # If the suffix is less than 16, it's probably not the size
        return (0, 0)

    return (suffix, suffix)


def image_data_uri(base64_source: bytes) -> str:
    """This returns data URL scheme according RFC 2397
    (https://tools.ietf.org/html/rfc2397) for all kind of supported images
    (PNG, GIF, JPG and SVG), defaulting on PNG type if not mimetype detected.
    """
    return 'data:image/%s;base64,%s' % (
        FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png'),
        base64_source.decode(),
    )


def get_saturation(rgb):
    """Returns the saturation (hsl format) of a given rgb color

    :param rgb: rgb tuple or list
    :return: saturation
    """
    c_max = max(rgb) / 255
    c_min = min(rgb) / 255
    d = c_max - c_min
    return 0 if d == 0 else d / (1 - abs(c_max + c_min - 1))


def get_lightness(rgb):
    """Returns the lightness (hsl format) of a given rgb color

    :param rgb: rgb tuple or list
    :return: lightness
    """
    return (max(rgb) + min(rgb)) / 2 / 255


def hex_to_rgb(hx):
    """Converts an hexadecimal string (starting with '#') to a RGB tuple"""
    return tuple([int(hx[i:i+2], 16) for i in range(1, 6, 2)])


def rgb_to_hex(rgb):
    """Converts a RGB tuple or list to an hexadecimal string"""
    return '#' + ''.join([(hex(c).split('x')[-1].zfill(2)) for c in rgb])