#!/usr/bin/python
# -*- coding: utf-8 -*-

# Hive Budy
# Copyright (c) 2008-2020 Hive Solutions Lda.
#
# This file is part of Hive Budy.
#
# Hive Budy is free software: you can redistribute it and/or modify
# it under the terms of the Apache License as published by the Apache
# Foundation, either version 2.0 of the License, or (at your option) any
# later version.
#
# Hive Budy 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
# Apache License for more details.
#
# You should have received a copy of the Apache License along with
# Hive Budy. If not, see <http://www.apache.org/licenses/>.

__author__ = "João Magalhães <joamag@hive.pt>"
""" The author(s) of the module """

__version__ = "1.0.0"
""" The version of the module """

__revision__ = "$LastChangedRevision$"
""" The revision number of the module """

__date__ = "$LastChangedDate$"
""" The last change date of the module """

__copyright__ = "Copyright (c) 2008-2020 Hive Solutions Lda."
""" The copyright for the module """

__license__ = "Apache License, Version 2.0"
""" The license for the module """

import json
import commons

import appier
import appier_extras

from . import base
from . import bundle
from . import currency as _currency

class Product(base.BudyBase):

    GENDER_S = {
        "Male" : "Male",
        "Female" : "Female",
        "Child" : "Child",
        "Both" : "Both"
    }
    """ The dictionary that maps the multiple gender
    enumeration values with their string representation """

    short_description = appier.field(
        index = "hashed",
        default = True,
        observations = """A short description on the product
        that should be used as the title for the product"""
    )

    product_id = appier.field(
        index = "hashed",
        description = "Product ID",
        observations = """The primary identifier of the
        product, should be globally unique"""
    )

    supplier_code = appier.field(
        index = "hashed",
        observations = """The identifier used by the
        (back) supplier of the product, should be considered
        a more unique way of identifying a product"""
    )

    sku = appier.field(
        index = "hashed",
        description = "SKU",
        observations = """The main identifier to be used for
        the keeping of the internal reference SKU (Stock Keeping
        Unit), should be considered the public way of representing
        the product"""
    )

    upc = appier.field(
        index = "hashed",
        description = "UPC",
        observations = """The standard Universal Product Code
        (UPC) value for this product"""
    )

    ean = appier.field(
        index = "hashed",
        description = "EAN",
        observations = """The standard European Article Number
        (EAN) value for this product"""
    )

    gender = appier.field(
        index = "hashed",
        meta = "enum",
        enum = GENDER_S
    )

    weight = appier.field(
        type = commons.Decimal,
        index = True,
        observations = """The weight of the current product in
        a unit defined by convention (defined before-hand)"""
    )

    quantity_hand = appier.field(
        type = commons.Decimal,
        index = True,
        observations = """The total quantity that is currently
        available (on hand) for the current product, if not
        set the product is considered to have unlimited quantity
        (inventory is not controller)"""
    )

    quantity_reserved = appier.field(
        type = commons.Decimal,
        index = True,
        observations = """The total quantity that is currently
        reserved (not available) for the current product, if not
        set the product is considered to not have reservation
        control enabled"""
    )

    price = appier.field(
        type = commons.Decimal,
        index = True,
        initial = commons.Decimal(0.0),
        observations = """Main retail price to be used for
        a possible sale transaction of the product (includes taxes)"""
    )

    price_compare = appier.field(
        type = commons.Decimal,
        index = True,
        initial = commons.Decimal(0.0),
        observations = """The price that is going to be used
        as the base for discount calculation purposes"""
    )

    taxes = appier.field(
        type = commons.Decimal,
        index = True,
        initial = commons.Decimal(0.0),
        observations = """The amount of taxes that are associated
        with the product this value can include the VAT value but
        it's not limited to it and more tax values can be included"""
    )

    currency = appier.field(
        index = "hashed"
    )

    order = appier.field(
        type = int,
        index = "hashed"
    )

    tag = appier.field()

    tag_description = appier.field()

    price_provider = appier.field(
        index = True
    )

    discountable = appier.field(
        type = bool,
        initial = True,
        observations = """Flag that indicates if the product is
        eligible for any kind of global discount"""
    )

    orderable = appier.field(
        type = bool,
        observations = """Flag that controls if the product is only
        meant to be used for pre-ordering and not direct sales"""
    )

    price_url = appier.field(
        index = "hashed",
        meta = "url",
        description = "Price URL"
    )

    farfetch_url = appier.field(
        index = "hashed",
        meta = "url",
        description = "Farfetch URL"
    )

    farfetch_male_url = appier.field(
        index = "hashed",
        meta = "url",
        description = "Farfetch Male URL"
    )

    farfetch_female_url = appier.field(
        index = "hashed",
        meta = "url",
        description = "Farfetch Female URL"
    )

    image_url = appier.field(
        index = "hashed",
        meta = "image_url",
        description = "Image URL"
    )

    thumbnail_url = appier.field(
        index = "hashed",
        meta = "image_url",
        description = "Thumbnail URL",
        observations = """The URL to the thumbnail image (low resolution)
        to be used in listing contexts"""
    )

    characteristics = appier.field(
        type = list,
        observations = """The sequence of characteristics that
        define the product under unstructured language"""
    )

    features = appier.field(
        type = dict,
        observations = """Dictionary that maps a set of optional
        features with their corresponding configuration, for example
        the `initials` feature can be associated with the rules
        that control those same initials"""
    )

    labels = appier.field(
        type = list,
        observations = """Set of simple tag labels that define the
        product behavior, this value is a calculated one based on
        the labels provided by the complete set of collections"""
    )

    brand_s = appier.field(
        index = "hashed"
    )

    season_s = appier.field(
        index = "hashed"
    )

    color_s = appier.field(
        index = "hashed"
    )

    category_s = appier.field(
        index = "hashed"
    )

    collection_s = appier.field(
        index = "hashed"
    )

    colors = appier.field(
        type = appier.references(
            "Color",
            name = "id"
        )
    )

    categories = appier.field(
        type = appier.references(
            "Category",
            name = "id"
        )
    )

    collections = appier.field(
        type = appier.references(
            "Collection",
            name = "id"
        )
    )

    variants = appier.field(
        type = appier.references(
            "Product",
            name = "id"
        )
    )

    images = appier.field(
        type = appier.references(
            "Media",
            name = "id"
        )
    )

    brand = appier.field(
        type = appier.reference(
            "Brand",
            name = "id"
        )
    )

    season = appier.field(
        type = appier.reference(
            "Season",
            name = "id"
        )
    )

    measurements = appier.field(
        type = appier.references(
            "Measurement",
            name = "id"
        )
    )

    compositions = appier.field(
        type = appier.references(
            "Composition",
            name = "id"
        )
    )

    live_model = appier.field(
        type = appier.references(
            "LiveModel",
            name = "id"
        )
    )

    @classmethod
    def validate(cls):
        return super(Product, cls).validate() + [
            appier.not_null("short_description"),
            appier.not_empty("short_description"),

            appier.not_null("gender"),
            appier.not_empty("gender"),

            appier.not_null("price"),
            appier.gte("price", 0.0),

            appier.not_null("taxes"),
            appier.gte("taxes", 0.0),

            appier.not_empty("brand_s"),

            appier.not_empty("season_s"),

            appier.not_empty("color_s"),

            appier.not_empty("category_s"),

            appier.not_empty("collection_s")
        ]

    @classmethod
    def list_names(cls):
        return ["id", "product_id", "short_description", "enabled", "gender", "tag", "image_url"]

    @classmethod
    def order_name(cls):
        return ["id", -1]

    @classmethod
    def index_names(cls):
        return super(Product, cls).index_names() + ["product_id"]

    @classmethod
    def token_names(cls):
        return super(Product, cls).token_names() + [
            ("short_description", True),
            ("product_id", False),
            ("supplier_code", False),
            ("sku", False),
            ("upc", False),
            ("ean", False),
            ("brand.name", True),
            ("season.name", True),
            ("characteristics", False),
            ("features", False),
            ("colors.name", True),
            ("categories.name", True),
            ("collections.name", True),
            ("compositions.name", True)
        ]

    @classmethod
    def from_omni(
        cls,
        merchandise,
        inventory_line = None,
        inventory_lines = None,
        gender = "Both",
        currency = "EUR",
        patch = True,
        force = False
    ):
        from . import brand
        from . import color
        from . import season
        from . import category
        from . import collection

        # gathers the complete set of element from the Omni
        # related merchandise element (product or sub-product)
        object_id = merchandise["object_id"]
        modify_date = merchandise["modify_date"]
        company_product_code = merchandise["company_product_code"]
        upc = merchandise["upc"]
        ean = merchandise["ean"]
        weight = merchandise["weight"]
        metadata = merchandise["metadata"] or dict()
        price_compare = metadata.get("compare_price") or None
        discount = metadata.get("discount") or None
        _color = metadata.get("material") or []
        _category = metadata.get("category") or []
        _collection = metadata.get("collection") or []
        _brand = metadata.get("brand")
        _season = metadata.get("season")
        gender = metadata.get("gender") or gender
        order = metadata.get("order")
        discountable = metadata.get("discountable", True)
        orderable = metadata.get("orderable", False)
        sku_field = metadata.get("sku_field")

        # verifies if an inventory line has been provided, if that's the case
        # it's possible to determine a proper modification date for the product
        # taking into account also the modification date of its inventory line
        if inventory_line:
            modify_date_line = inventory_line["modify_date"]
            if modify_date_line > modify_date: modify_date = modify_date_line

        # creates the stocks list in case there are valid inventory lines being
        # passed on the current product update/creation
        stocks = None if inventory_lines == None else []

        # iterates over the complete set of available inventory lines to build the
        # associated stock dictionary with the information on the stock point, this
        # is going to be added to the list of stocks to the product
        for inventory_line in inventory_lines if inventory_lines else []:
            stock_on_hand = inventory_line.get("stock_on_hand", 0)
            stock_reserved = inventory_line.get("stock_reserved", 0)
            stock_in_transit = inventory_line.get("stock_in_transit", 0)
            retail_price = inventory_line.get("retail_price", {}).get("value", 0.0)
            functional_unit = inventory_line.get("functional_unit", None)

            is_valid = functional_unit and functional_unit.get("status") == 1
            if not is_valid: continue

            stock_m = dict(
                store_id = functional_unit["object_id"],
                store_name = functional_unit["name"],
                stock_on_hand = stock_on_hand,
                stock_reserved = stock_reserved,
                stock_in_transit = stock_in_transit,
                retail_price = retail_price
            )
            stocks.append(stock_m)

        colors = _color if isinstance(_color, list) else [_color]
        categories = _category if isinstance(_category, list) else [_category]
        collections = _collection if isinstance(_collection, list) else [_collection]
        colors = [color.Color.ensure_s(_color) for _color in colors]
        categories = [category.Category.ensure_s(_category) for _category in categories]
        collections = [collection.Collection.ensure_s(_collection) for _collection in collections]
        if _brand: _brand = brand.Brand.ensure_s(_brand)
        if _season: _season = season.Season.ensure_s(_season)
        product = cls.get(product_id = company_product_code, raise_e = False)
        if not product: product = cls()

        # in case the weight contains the special 1.0 value then
        # it must be set to invalid as this is considered to be
        # a dummy (and invalid value), this is only performed in
        # case there's a valid season value (eg: watches) set and
        # the patch mode is set (to enable automatic data correction)
        # this is considered a hack to reverse the invalid data source
        # values and should be used with proper care
        if patch and weight == 1.0 and _season: weight = None

        product.product_id = company_product_code
        product.supplier_code = upc
        product.sku = merchandise.get(sku_field) or upc or company_product_code
        product.upc = upc
        product.ean = ean
        product.weight = weight
        product.short_description = merchandise["name"] or company_product_code
        product.description = merchandise["description"]
        product.gender = gender
        product.price_compare = price_compare
        product.currency = currency
        product.order = order
        product.discountable = discountable
        product.orderable = orderable
        product.characteristics = metadata.get("characteristics", [])
        product.features = metadata.get("features", {})
        product.colors = colors
        product.categories = categories
        product.collections = collections
        product.brand = _brand
        product.season = _season

        meta = dict(
            object_id = object_id,
            company_product_code = company_product_code,
            modify_date = modify_date,
            discount = discount
        )
        if hasattr(product, "meta") and product.meta: product.meta.update(meta)
        else: product.meta = meta
        if not stocks == None: product.meta["stocks"] = stocks

        if "stock_on_hand" in merchandise or force:
            product.quantity_hand = merchandise.get("stock_on_hand", 0.0)
        if "retail_price" in merchandise or force:
            # "grabs" the retail price from the original merchandise entity
            # from Omni to be used as the base calculus
            retail_price = merchandise.get("retail_price", 0.0)

            # stores the "original" retail price in the product's metadata
            # storage may be needed latter for update operations
            product.meta["retail_price"] = retail_price
        if "price" in merchandise or force:
            # "grabs" the (untaxed) price from the original merchandise entity
            # from Omni to be used as the base calculus
            untaxed_price = merchandise.get("price", 0.0)

            # stores the "original" untaxed price in the product's metadata
            # storage may be needed latter for update operations
            product.meta["untaxed_price"] = untaxed_price

        # in case all of the required "original" financial information (prices)
        # is available then the price, taxes and price compare are calculated
        if "retail_price" in product.meta and "untaxed_price" in product.meta:
            untaxed_price = _currency.Currency.round(
                product.meta["untaxed_price"] * ((100.0 - discount) / 100.0),
                currency
            ) if discount else product.meta["untaxed_price"]
            product.price = _currency.Currency.round(
                product.meta["retail_price"] * ((100.0 - discount) / 100.0),
                currency
            ) if discount else product.meta["retail_price"]
            product.taxes = product.price - untaxed_price
            if not product.price_compare and discount:
                product.price_compare = product.meta["retail_price"]

        # returns the "final" product instance to the caller so that it's possible
        # to properly save the newly generated product instance according to omni
        return product

    @classmethod
    @appier.operation(
        name = "Import Omni",
        parameters = (
            ("Product", "product", "longtext"),
        ),
        factory = True
    )
    def import_omni_s(cls, product, safe = True):
        product = json.loads(product)
        product = cls.from_omni(product)
        product.save()
        return product

    @classmethod
    @appier.operation(
        name = "Import CSV",
        parameters = (
            ("CSV File", "file", "file"),
            ("Empty source", "empty", bool, False)
        )
    )
    def import_csv_s(cls, file, empty):

        def callback(line):
            from . import color
            from . import brand
            from . import season
            from . import category
            from . import collection
            from . import composition
            from . import measurement

            description,\
            short_description,\
            product_id,\
            gender,\
            price,\
            order,\
            tag,\
            tag_description,\
            farfetch_url,\
            farfetch_male_url,\
            farfetch_female_url,\
            colors,\
            categories,\
            collections, \
            variants,\
            _brand,\
            _season,\
            measurements,\
            compositions, \
            price_provider, \
            price_url = line

            product_id = product_id or None
            price = float(price) if price else None
            order = int(order) if order else None
            tag = tag or None
            tag_description = tag_description or None
            farfetch_url = farfetch_url or None
            farfetch_male_url = farfetch_male_url or None
            farfetch_female_url = farfetch_female_url or None
            price_provider = price_provider or None
            price_url = price_url or None

            colors = colors.split(";") if colors else []
            colors = color.Color.find(name = {"$in" : colors})

            categories = categories.split(";") if categories else []
            categories = category.Category.find(name = {"$in" : categories})

            collections = collections.split(";") if collections else []
            collections = collection.Collection.find(name = {"$in" : collections})

            variants = variants.split(";") if variants else []
            variants = Product.find(product_id = {"$in" : variants})

            _brand = brand.Brand.find(name = _brand) if _brand else None
            _season = season.Season.find(name = _season) if _season else None

            measurements = measurements.split(";") if measurements else []
            measurements = measurement.Measurement.find(name = {"$in" : measurements})

            compositions = compositions.split(";") if compositions else []
            compositions = composition.Composition.find(name = {"$in" : compositions})

            product = cls(
                description = description,
                short_description = short_description,
                product_id = product_id,
                gender = gender,
                price = price,
                order = order,
                tag = tag,
                tag_description = tag_description,
                farfetch_url = farfetch_url,
                farfetch_male_url = farfetch_male_url,
                farfetch_female_url = farfetch_female_url,
                colors = colors,
                categories = categories,
                collections = collections,
                variants = variants,
                brand = _brand,
                season = _season,
                measurements = measurements,
                compositions = compositions,
                price_provider = price_provider,
                price_url = price_url
            )
            product.save()

        if empty: cls.delete_c()
        cls._csv_import(file, callback)

    @classmethod
    @appier.link(name = "Export Simple")
    def simple_csv_url(cls, absolute = False):
        return appier.get_app().url_for(
            "product_api.simple_csv",
            absolute = absolute
        )

    @classmethod
    def _build(cls, model, map):
        super(Product, cls)._build(model, map)

        # retrieves the multiple values from the product model that are
        # going to be used in the calculated attributes construction
        price = model.get("price", None)
        price_compare = model.get("price_compare", None)
        labels = model.get("labels", ())

        # verifies if the current product is considered to be a discounted
        # one, that happens when the price compare is greater than price
        is_discounted = bool(price and price_compare and price_compare > price)

        # calculates the shipping costs taking into account if the price
        # is currently defined for the product defaulting to none otherwise
        if price == None: shipping_cost = None
        else: shipping_cost = bundle.Bundle.eval_shipping(price, 0.0, 1.0, None)

        # sets the multiple attributes of the product that describe it through
        # calculated attributes (as expected by model retriever)
        model["is_discounted"] = is_discounted
        model["is_discounted_s"] = "discounted" if is_discounted else "not-discounted"
        model["shipping_cost"] = shipping_cost
        for label in labels:
            model[label] = label
            model[label + "_s"] = "true"

    def pre_validate(self):
        base.BudyBase.pre_validate(self)
        self.build_images()
        self.build_names()
        self.build_labels()

    def pre_save(self):
        base.BudyBase.pre_save(self)
        if not self.measurements: return
        quantities_hand = [measurement.quantity_hand or 0.0 for measurement in\
            self.measurements if hasattr(measurement, "quantity_hand") and\
            not measurement.quantity_hand == None]
        prices = [measurement.price or 0.0 for measurement in\
            self.measurements if hasattr(measurement, "price") and\
            not measurement.price == None]
        taxes = [measurement.taxes or 0.0 for measurement in\
            self.measurements if hasattr(measurement, "taxes") and\
            not measurement.taxes == None]
        prices_compare = [measurement.price_compare or 0.0 for measurement in\
            self.measurements if hasattr(measurement, "price_compare") and\
            not measurement.price_compare == None]
        self.quantity_hand = sum(quantities_hand) if quantities_hand else None
        self.price = max(prices) if prices else 0.0
        self.taxes = max(taxes) if taxes else 0.0
        self.price_compare = max(prices_compare) if prices_compare else None

        # in case the product is orderable then the quantity on hand must
        # be forced to be greater than zero so that the product can be
        # listed as available (otherwise it would not be listed), this is
        # considered to be a "hack"
        if self.orderable and self.quantity_hand <= 0:
            self.quantity_hand = 1

    def build_images(self):
        thumbnail = self.get_image(size = "thumbnail", order = 1)
        thumbnail = thumbnail or self.get_image(size = "thumbnail")
        image = self.get_image(size = "large", order = 1)
        image = image or self.get_image(size = "large")
        self.thumbnail_url = thumbnail.get_url() if thumbnail else None
        self.image_url = image.get_url() if image else None

    def build_names(self):
        self.brand_s = self.brand.name if self.brand else None
        self.season_s = self.season.name if self.season else None
        self.color_s = self.colors[0].name if self.colors else None
        self.category_s = self.categories[0].name if self.categories else None
        self.collection_s = self.collections[0].name if self.collections else None

    def build_labels(self):
        self._reset_labels()
        self._build_labels(self.brand)
        self._build_labels(self.season)
        self._build_labels(self.colors)
        self._build_labels(self.categories)
        self._build_labels(self.collections)

    def related(self, limit = 6, available = True, enabled = True):
        cls = self.__class__
        kwargs = dict()
        if available: kwargs["quantity_hand"] = {"$gt" : 0}
        if self.collections: kwargs["collections"] = {"$in" : [self.collections[0].id]}
        elif self.categories: kwargs["categories"] = {"$in" : [self.categories[0].id]}
        elif self.colors: kwargs["colors"] = {"$in" : [self.colors[0].id]}
        elif self.brand: kwargs["brand"] = {"$in" : [self.brand.id]}
        elif self.season: kwargs["season"] = {"$in" : [self.season.id]}
        kwargs["id"] = {"$nin" : [self.id]}
        kwargs["sort"] = [("id", 1)]
        count = cls.count(**kwargs)
        skip = self._get_offset(count, limit, kwargs = kwargs)
        delta = skip + limit - count
        if delta > 0: skip = count - skip - delta
        if skip < 0: skip = 0
        find = cls.find_e if enabled else cls.find
        products = find(
            eager = ("images",),
            skip = skip,
            limit = limit,
            map = True,
            **kwargs
        )
        return products

    def get_measurement(self, value, name = None):
        for measurement in self.measurements:
            if not measurement: continue
            if not hasattr(measurement, "value"): continue
            if not hasattr(measurement, "name"): continue
            if not measurement.value == value: continue
            if not measurement.name == name: continue
            return measurement
        return None

    def get_price(
        self,
        currency = None,
        country = None,
        attributes = None
    ):
        if not self.price_provider: return self.price
        method = getattr(self, "get_price_%s" % self.price_provider)
        return method(
            currency = currency,
            country = country,
            attributes = attributes
        )

    def get_taxes(
        self,
        currency = None,
        country = None,
        attributes = None
    ):
        if not self.price_provider: return self.taxes
        method = getattr(self, "get_taxes_%s" % self.price_provider, None)
        if not method: return self.taxes
        return method(
            currency = currency,
            country = country,
            attributes = attributes
        )

    def get_price_ripe(
        self,
        currency = None,
        country = None,
        attributes = None
    ):
        if not self.price_url: return self.price

        result = self.get_availability_ripe(
            currency = currency,
            country = country,
            attributes = attributes
        )
        total = result["total"]
        return total["price_final"]

    def get_taxes_ripe(
        self,
        currency = None,
        country = None,
        attributes = None
    ):
        if not self.price_url: return self.price

        result = self.get_availability_ripe(
            currency = currency,
            country = country,
            attributes = attributes
        )
        total = result["total"]
        return total["ddp"] + total["vat"]

    def get_availability_ripe(
        self,
        currency = None,
        country = None,
        attributes = None
    ):
        attributes_m = json.loads(attributes)
        p = []
        parts = attributes_m.get("parts", {})
        embossing = attributes_m.get("embossing", None)
        letters = attributes_m.get("letters", None)

        for key, value in appier.legacy.iteritems(parts):
            material = value["material"]
            color = value["color"]
            triplet = "%s:%s:%s" % (key, material, color)
            p.append(triplet)

        params = dict(
            product_id = self.product_id,
            p = p
        )
        if currency: params["currency"] = currency
        if country: params["country"] = country
        if embossing: params["embossing"] = embossing
        if letters: params["letters"] = letters

        result = appier.get(
            self.price_url,
            params = params
        )
        return result

    def get_currency(self, currency = None):
        if not self.price_provider: return self.currency or currency
        method = getattr(self, "get_currency_%s" % self.price_provider)
        return method(currency = currency)

    def get_currency_ripe(self, currency = None):
        return self.currency or currency

    def get_image(self, size = None, order = None):
        for image in self.images:
            if not image: continue
            if not hasattr(image, "size"): continue
            if not hasattr(image, "order"): continue
            is_size = size == None or image.size == size
            if not is_size: continue
            is_order = order == None or image.order == order
            if not is_order: continue
            return image
        return None

    def get_size(self, currency = None, country = None, attributes = None):
        if not self.price_provider: return None, None
        method = getattr(self, "get_size_%s" % self.price_provider)
        return method(
            country = country,
            attributes = attributes
        )

    def get_size_ripe(
        self,
        currency = None,
        country = None,
        attributes = None
    ):
        attributes_m = json.loads(attributes)
        size = attributes_m["size"]
        scale = attributes_m["scale"]
        gender = attributes_m["gender"]

        if gender == "male": converter = lambda native: ((native - 17) / 2) + 36
        else: converter = lambda native: ((native - 17) / 2) + 34

        return converter(size), scale

    @appier.operation(
        name = "Add Collection",
        parameters = (
            (
                "Collection",
                "collection",
                appier.reference("Collection", name = "id")
            ),
        )
    )
    def add_collection_s(self, collection):
        if not collection: return
        if collection in self.collections: return
        self.collections.append(collection)
        self.save()

    @appier.operation(
        name = "Remove Collection",
        parameters = (
            (
                "Collection",
                "collection",
                appier.reference("Collection", name = "id")
            ),
        )
    )
    def remove_collection_s(self, collection):
        if not collection: return
        if not collection in self.collections: return
        self.collections.remove(collection)
        self.save()

    @appier.operation(
        name = "Add Image",
        parameters = (
            (
                "Image",
                "image",
                appier.reference("Media", name = "id")
            ),
        )
    )
    def add_image_s(self, image):
        if not image: return
        if image in self.images: return
        self.images.append(image)
        self.save()

    @appier.operation(
        name = "Remove Image",
        parameters = (
            (
                "Image",
                "image",
                appier.reference("Media", name = "id")
            ),
        )
    )
    def remove_image_s(self, image):
        if not image: return
        if not image in self.images: return
        self.images.remove(image)
        self.save()

    @appier.operation(name = "Fix")
    def fix_s(self):
        if not self.exists(): return
        self.save()

    @appier.operation(name = "Ensure Quantity", level = 2, devel = True)
    def ensure_quantity_s(self, quantity = 1.0):
        if self.quantity_hand: return
        self.quantity_hand = quantity
        self.save()

    @appier.operation(name = "Ensure Price", level = 2, devel = True)
    def ensure_price_s(self, price = 100.0):
        if self.price: return
        self.price = price
        self.save()

    @appier.operation(
        name = "Notify",
        parameters = (("Email", "email", str),)
    )
    def notify(self, name = None, *args, **kwargs):
        name = name or "product.new"
        product = self.reload(map = True)
        receiver = kwargs.get("email", None)
        appier_extras.admin.Event.notify_g(
            name,
            arguments = dict(
                params = dict(
                    payload = product,
                    product = product,
                    receiver = receiver,
                    extra = kwargs
                )
            )
        )

    @appier.operation(
        name = "Share",
        parameters = (
            ("Sender", "sender", appier.legacy.UNICODE),
            ("Email", "email", str)
        )
    )
    def share(self, *args, **kwargs):
        self.notify(name = "product.share", *args, **kwargs)

    @appier.operation(
        name = "Quote",
        parameters = (
            ("Requester", "requester", appier.legacy.UNICODE),
            ("Email", "email", str),
            ("Phone", "phone", str)
        )
    )
    def quote(self, *args, **kwargs):
        # patches the email attribute for the quote call so
        # that the receiver value can be "manipulated" by
        # configuration as it must be a admin user of the
        # store by default and not the end-user
        kwargs_quote = dict(kwargs)
        kwargs_quote["_email"] = kwargs_quote.pop("email", None)

        # runs the notification process for the product quote making
        # sure that all of the notification names are used
        self.notify(name = "product.quote", *args, **kwargs_quote)
        self.notify(name = "product.quote.confirmation", *args, **kwargs)

    @appier.view(name = "Measurements")
    def measurements_v(self, *args, **kwargs):
        kwargs["sort"] = kwargs.get("sort", [("id", 1)])
        return appier.lazy_dict(
            model = self.measurements._target,
            kwargs = kwargs,
            entities = appier.lazy(lambda: self.measurements.find(*args, **kwargs)),
            page = appier.lazy(lambda: self.measurements.paginate(*args, **kwargs))
        )

    @property
    def quantity(self):
        return self.quantity_hand

    @property
    def discount(self):
        if not self.price: return commons.Decimal(0.0)
        if not self.price_compare: return commons.Decimal(0.0)
        return self.price_compare - self.price

    @property
    def discount_percent(self):
        if not self.discount: return commons.Decimal(0.0)
        return self.discount / self.price_compare * commons.Decimal(100.0)

    @property
    def is_parent(self):
        if not hasattr(self, "measurements"): return False
        if not self.measurements: return False
        return len(self.measurements) > 0

    @property
    def is_discounted(self):
        return self.discount > 0.0

    @property
    def is_discountable(self):
        return self.discountable

    @property
    def is_price_provided(self):
        return True if self.price_provider else False

    def _reset_labels(self):
        self.labels = []

    def _build_labels(self, groups):
        if not isinstance(groups, (list, tuple, appier.References)):
            groups = (groups,) if groups else []
        for group in groups:
            for label in group.labels:
                if label in self.labels: continue
                self.labels.append(label)

    def _get_offset(self, count, limit, kwargs):
        return self._get_offset_offset(count, limit, kwargs)

    def _get_offset_simple(self, count, limit, kwargs):
        return self.id % count

    def _get_offset_offset(self, count, limit, kwargs):
        cls = self.__class__
        kwargs = dict(kwargs)
        kwargs["id"] = {"$lt" : self.id}
        offset = cls.count(**kwargs)
        return offset
