# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import base64
import io
import math
from urllib.parse import quote, urlencode, urljoin

import PIL
import PIL.Image

import trytond.config as config
from trytond.i18n import gettext
from trytond.model import (
    MatchMixin, ModelSQL, ModelView, Unique, fields, sequence_ordered)
from trytond.modules.product import (
    copy_product_filtered, copy_template_filtered)
from trytond.pool import PoolMeta
from trytond.pyson import Bool, Eval, If
from trytond.tools import slugify
from trytond.transaction import Transaction
from trytond.url import http_base
from trytond.wsgi import Base64Converter

from .exceptions import ImageValidationError

if config.getboolean('product', 'image_filestore', default=False):
    file_id = 'image_id'
    store_prefix = config.get('product', 'image_prefix', default=None)
else:
    file_id = None
    store_prefix = None
SIZE_MAX = config.getint('product', 'image_size_max', default=2048)


class ImageURLMixin:
    __slots__ = ()
    __image_url__ = None
    images = None
    image_url = fields.Function(fields.Char("Image URL"), '_get_image_url')

    def _get_image_url(self, name):
        return self.get_image_url(s=64)

    def _image_url(self, base, **args):
        if self.code and list(self.images_used):
            url = urljoin(
                base, quote('%(prefix)s/%(code)s/%(database)s/%(name)s' % {
                        'prefix': self.__image_url__,
                        'database': Base64Converter(None).to_url(
                            Transaction().database.name),
                        'code': quote(self.code, ''),
                        'name': slugify(self.name),
                        }))
            if args:
                size = args.pop('s', None)
                width = args.pop('w', None)
                height = args.pop('h', None)
                index = args.pop('i', None)
                id = args.pop('id', None)
                args = {k: int(bool(v)) for k, v in args.items()}
                if size:
                    args['s'] = size
                if width:
                    args['w'] = width
                if height:
                    args['h'] = height
                if index is not None:
                    args['i'] = index
            timestamp = int(self.last_modified_at.timestamp())
            args['t'] = (
                base64.urlsafe_b64encode(timestamp.to_bytes(8, 'big'))
                .decode().rstrip('='))
            if id is not None:
                args['id'] = id
            url += '?' + urlencode(args)
            return url

    def get_image_url(self, _external=False, **args):
        url_base = config.get(
            'product', 'image_base', default='')
        url_external_base = config.get(
            'product', 'image_base', default=http_base())
        return self._image_url(
            url_external_base if _external else url_base, **args)

    @property
    def images_used(self):
        yield from self.images

    def get_images(self, pattern):
        Image = self.__class__.images.get_target()
        pattern = pattern.copy()
        for key in set(pattern.keys()) - Image.allowed_match_keys():
            del pattern[key]
        pattern = {k: bool(int(v)) for k, v in pattern.items()}
        for image in self.images_used:
            if image.match(pattern, match_none=True):
                yield image


class Template(
        copy_template_filtered('images'),
        ImageURLMixin, metaclass=PoolMeta):
    __name__ = 'product.template'
    __image_url__ = '/product/image'
    images = fields.One2Many('product.image', 'template', "Images")


class Product(
        copy_product_filtered('images'),
        ImageURLMixin, metaclass=PoolMeta):
    __name__ = 'product.product'
    __image_url__ = '/product/variant/image'
    images = fields.One2Many(
        'product.image', 'product', "Images",
        domain=[
            ('template', '=', Eval('template', -1)),
            ])

    @property
    def images_used(self):
        yield from super().images_used
        for image in self.template.images_used:
            if not image.product:
                yield image


class _ImageMixin:
    __slots__ = ()
    image = fields.Binary(
        "Image", file_id=file_id, store_prefix=store_prefix, required=True)
    image_id = fields.Char("Image ID", readonly=True)


class ImageMixin(_ImageMixin):
    __slots__ = ()
    cache = None

    description = fields.Char("Description", translate=True)

    @classmethod
    def allowed_match_keys(cls):
        return {'id'}

    @classmethod
    def preprocess_values(cls, mode, values):
        values = super().preprocess_values(mode, values)
        if values.get('image'):
            values['image'] = cls.convert(values['image'])
        return values

    @classmethod
    def on_modification(cls, mode, images, field_names=None):
        super().on_modification(mode, images, field_names=field_names)
        if mode == 'write':
            cls.clear_cache(images)

    @classmethod
    def _round_size(cls, size):
        return min((
                2 ** math.ceil(math.log2(size)),
                10 * math.ceil(size / 10) if size <= 100
                else 50 * math.ceil(size / 50)))

    def get(self, size=400):
        if isinstance(size, int):
            size = (size, size)
        size = tuple(map(self._round_size, size))
        if not all(0 < s <= SIZE_MAX for s in size):
            raise ValueError(f"Invalid size {size}")
        for cache in self.cache:
            if (cache.width, cache.height) == size:
                # re-instantiate to fetch only one image
                return cache.__class__(cache.id).image
        with Transaction().new_transaction():
            cache = self._store_cache(size, self._resize(size))
            # Save cache only if record is already committed
            if self.__class__.search([('id', '=', self.id)]):
                cache.save()
            return cache.image

    @classmethod
    def convert(cls, image, **_params):
        data = io.BytesIO()
        try:
            img = PIL.Image.open(io.BytesIO(image))
        except PIL.UnidentifiedImageError as e:
            raise ImageValidationError(gettext(
                    'product_image.msg_product_image_error'), str(e)) from e
        img.thumbnail((SIZE_MAX, SIZE_MAX))
        if img.mode != 'RGB':
            img = img.convert('RGBA')
            background = PIL.Image.new('RGBA', img.size, (255, 255, 255))
            background.alpha_composite(img)
            img = background.convert('RGB')
        img.save(data, format='jpeg', optimize=True, dpi=(300, 300), **_params)
        return data.getvalue()

    def _resize(self, size=64, **_params):
        data = io.BytesIO()
        img = PIL.Image.open(io.BytesIO(self.image))
        if isinstance(size, int):
            size = (size, size)
        img.thumbnail(size)
        img.save(data, format='jpeg', optimize=True, dpi=(300, 300), **_params)
        return data.getvalue()

    def _store_cache(self, size, image):
        Cache = self.__class__.cache.get_target()
        if isinstance(size, int):
            width = height = size
        else:
            width, height = size
        return Cache(
            image=image,
            width=width,
            height=height)

    @classmethod
    def clear_cache(cls, images):
        Cache = cls.cache.get_target()
        caches = [c for i in images for c in i.cache]
        Cache.delete(caches)


class Image(ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin):
    __name__ = 'product.image'
    template = fields.Many2One(
        'product.template', "Product",
        required=True, ondelete='CASCADE',
        domain=[
            If(Bool(Eval('product')),
                ('products', '=', Eval('product')),
                ()),
            ])
    product = fields.Many2One(
        'product.product', "Variant",
        domain=[
            If(Bool(Eval('template')),
                ('template', '=', Eval('template')),
                ()),
            ])
    cache = fields.One2Many(
        'product.image.cache', 'product_image', "Cache", readonly=True)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.__access__.add('template')

    def _store_cache(self, size, image):
        cache = super()._store_cache(size, image)
        cache.product_image = self
        return cache


class ImageCacheMixin(_ImageMixin):
    __slots__ = ()

    width = fields.Integer(
        "Width", required=True,
        domain=[
            ('width', '>', 0),
            ('width', '<=', SIZE_MAX),
            ])
    height = fields.Integer(
        "Height", required=True,
        domain=[
            ('height', '>', 0),
            ('height', '<=', SIZE_MAX),
            ])


class ImageCache(ImageCacheMixin, ModelSQL):
    __name__ = 'product.image.cache'
    product_image = fields.Many2One(
        'product.image', "Product Image", required=True, ondelete='CASCADE')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        t = cls.__table__()
        cls._sql_constraints += [
            ('dimension_unique', Unique(t, t.product_image, t.width, t.height),
                'product_image.msg_image_cache_size_unique'),
            ]

    @classmethod
    def __register__(cls, module):
        cursor = Transaction().connection.cursor()
        table = cls.__table__()
        table_h = cls.__table_handler__(module)

        super().__register__(module)

        # Migration from 7.0: split size into width and height
        table_h.drop_constraint('size_unique')
        if table_h.column_exist('size'):
            cursor.execute(*table.update(
                    [table.width, table.height],
                    [table.size, table.size]))
            table_h.drop_column('size')


class Category(ImageURLMixin, metaclass=PoolMeta):
    __name__ = 'product.category'
    __image_url__ = '/product-category/image'
    images = fields.One2Many('product.category.image', 'category', "Images")


class CategoryImage(
        ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin):
    __name__ = 'product.category.image'
    category = fields.Many2One(
        'product.category', "Category",
        required=True, ondelete='CASCADE')
    cache = fields.One2Many(
        'product.category.image.cache', 'category_image', "Cache",
        readonly=True)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.__access__.add('category')

    def _store_cache(self, size, image):
        cache = super()._store_cache(size, image)
        cache.category_image = self
        return cache


class CategoryImageCache(ImageCacheMixin, ModelSQL):
    __name__ = 'product.category.image.cache'
    category_image = fields.Many2One(
        'product.category.image', "Category Image", required=True,
        ondelete='CASCADE')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        t = cls.__table__()
        cls._sql_constraints += [
            ('dimension_unique',
                Unique(t, t.category_image, t.width, t.height),
                'product_image.msg_image_cache_size_unique'),
            ]
