# 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 datetime as dt
import logging
import urllib.parse
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from operator import attrgetter

from shopify_app import admin_graphql_request
from sql.functions import Position, Substring

import trytond.config as config
from trytond.cache import Cache
from trytond.i18n import gettext
from trytond.model import (
    MatchMixin, ModelSQL, ModelView, Unique, fields, sequence_ordered)
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.tools import grouped_slice
from trytond.tools.logging import format_args
from trytond.transaction import Transaction
from trytond.url import http_host

from . import SHOPIFY_VERSION, graphql
from .common import IdentifierMixin, IdentifiersMixin, gid2id, id2gid
from .exceptions import (
    GraphQLException, ShopifyCredentialWarning, ShopifyError)
from .product import QUERY_PRODUCT

logger = logging.getLogger(__name__)

EDIT_ORDER_DELAY = dt.timedelta(days=60 + 1)


QUERY_SHOP = '''{
    shop %(fields)s
}'''


QUERY_SHOP_LOCALES = '''{
    shopLocales %(fields)s
}'''

QUERY_LOCATIONS = '''
query GetLocations($cursor: String) {
    locations(
        first: 250, includeInactive: true, includeLegacy: true,
        after: $cursor) {
        nodes {
            id
            name
        }
        pageInfo {
            hasNextPage
            endCursor
        }
    }
}'''


MUTATION_PRODUCT_SET = '''\
mutation productSet($input: ProductSetInput!) {
    productSet(input: $input) {
        product %(fields)s,
        userErrors {
            field
            message
        }
    }
}'''


QUERY_PRODUCT_CURSOR = '''\
query GetProduct($id: ID!, $cursor: String) {
    product(id: $id) %(fields)s
}'''


MUTATION_PRODUCT_CHANGE_STATUS = '''\
mutation productChangeStatus($productId: ID!, $status: ProductStatus!) {
    productChangeStatus(productId: $productId, status: $status) {
        userErrors {
            field
            message
        }
    }
}'''

MUTATION_INVENTORY_ITEM_UPDATE = '''\
mutation inventoryItemUpdate($id: ID!, $input: InventoryItemInput!) {
    inventoryItemUpdate(id: $id, input: $input) {
        InventoryItem %(fields)s,
        userErrors {
            field
            message
        }
    }
}'''


MUTATION_INVENTORY_ACTIVATE = '''\
mutation ActivateInventoryItem($inventoryItemId: ID!, $locationId: ID!) {
    inventoryActivate(
            inventoryItemId: $inventoryItemId, locationId: $locationId) {
        userErrors {
            field
            message
        }
    }
}'''


MUTATION_INVENTORY_SET_QUANTITIES = '''\
mutation InventorySet($input: InventorySetQuantitiesInput!) {
    inventorySetQuantities(input: $input) {
        userErrors {
            field
            message
        }
    }
}'''


MUTATION_COLLECTION_CREATE = '''\
mutation createCollection($input: CollectionInput!) {
    collectionCreate(input: $input) {
        collection %(fields)s,
        userErrors {
            field
            message
        }
    }
}'''

MUTATION_COLLECTION_UPDATE = '''\
mutation updateCollection($input: CollectionInput!) {
    collectionUpdate(input: $input) {
        collection %(fields)s,
        userErrors {
            field
            message
        }
    }
}'''

MUTATION_COLLECTION_DELETE = '''\
mutation collectionDelete($input: CollectionDeleteInput!) {
    collectionDelete(input: $input) {
        userErrors {
            field
            message
        }
    }
}'''


QUERY_ORDERS = '''\
query GetOrders {
    orders(first: 20, query: "%(query)s", sortKey: ID) %(fields)s
}'''


class Shop(metaclass=PoolMeta):
    __name__ = 'web.shop'

    _states = {
        'required': Eval('type') == 'shopify',
        'invisible': Eval('type') != 'shopify',
        }

    shopify_shop_name = fields.Char("Shop Name", states=_states)
    shopify_url = fields.Function(
        fields.Char("Shop URL"),
        'on_change_with_shopify_url')
    shopify_access_token = fields.Char(
        "Access Token", states=_states, strip=False)
    shopify_webhook_shared_secret = fields.Char(
        "Webhook Shared Secret", strip=False,
        states={
            'invisible': _states['invisible'],
            })
    shopify_webhook_endpoint_order = fields.Function(
        fields.Char(
            "Webhook Order Endpoint",
            help="The URL to be called by Shopify for Order events."),
        'on_change_with_shopify_webhook_endpoint_order')
    shopify_warehouses = fields.One2Many(
        'web.shop-stock.location', 'shop', "Warehouses", states=_states)
    shopify_payment_journals = fields.One2Many(
        'web.shop.shopify_payment_journal', 'shop', "Payment Journals",
        states=_states)
    shopify_fulfillment_notify_customer = fields.Boolean(
        "Notify Customer about Fulfillment",
        states={
            'invisible': Eval('type') != 'shopify',
            })

    del _states

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.type.selection.append(('shopify', "Shopify"))
        invisible = Eval('type') == 'shopify'
        for field in [cls.attributes, cls.attributes_removed, cls.warehouses]:
            if field.states.get('invisible'):
                field.states['invisible'] |= invisible
            else:
                field.states['invisible'] = invisible

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

        super().__register__(module)

        # Migration from 7.8: replace url by shop_name
        if table_h.column_exist('shopify_url'):
            cursor.execute(*table.update(
                    [table.shopify_shop_name],
                    [Substring(
                            table.shopify_url,
                            Position('//', table.shopify_url) + 2,
                            Position('.', table.shopify_url)
                            - Position('//', table.shopify_url) - 2)]))
            table_h.drop_column('shopify_url')

        # Migration from 7.8: rename password into access_token
        table_h.column_rename('shopify_password', 'shopify_access_token')

    @fields.depends('shopify_shop_name')
    def on_change_with_shopify_url(self, name=None):
        if self.shopify_shop_name:
            return f"https://{self.shopify_shop_name}.myshopify.com/"

    @fields.depends('name')
    def on_change_with_shopify_webhook_endpoint_order(self, name=None):
        if not self.name:
            return
        url_part = {
            'database_name': Transaction().database.name,
            'shop': self.name,
            }
        return http_host() + (
            urllib.parse.quote(
                '/%(database_name)s/web_shop_shopify/webhook/%(shop)s/order' %
                url_part))

    @property
    def is_managing_gift_card(self):
        managing = super().is_managing_gift_card
        if self.type == 'shopify':
            managing = True
        return managing

    @classmethod
    def view_attributes(cls):
        return super().view_attributes() + [
            ('//page[@id="shopify"]', 'states', {
                    'invisible': Eval('type') != 'shopify',
                    }),
            ]

    @classmethod
    def validate_fields(cls, shops, field_names):
        super().validate_fields(shops, field_names)
        if field_names & {'type', 'products'}:
            for shop in shops:
                if shop.type == 'shopify':
                    for product in shop.products:
                        shop._shopify_check_product(product)

    def _shopify_check_product(self, product):
        if not product.template.shopify_uom:
            shopify_uom = product.template.get_shopify_uom()
            if shopify_uom.digits:
                raise ShopifyError(gettext(
                        'web_shop_shopify.'
                        'msg_product_shopify_uom_digits',
                        product=product.rec_name))

    @property
    def to_sync(self):
        result = super().to_sync
        if self.type == 'shopify':
            result = True
        return result

    def get_sale(self, party=None):
        sale = super().get_sale(party=party)
        if self.type == 'shopify':
            sale.invoice_method = 'fulfillment'
        return sale

    def update_sales(self, sales):
        super().update_sales(sales)
        if self.type == 'shopify':
            self._shopify_update_order(self, sales)

    def shopify_request(
            self, query, variables=None, headers=None, max_retries=2,
            user_errors=None):
        result = admin_graphql_request(
            query=query,
            shop=self.shopify_shop_name,
            access_token=self.shopify_access_token,
            api_version=SHOPIFY_VERSION,
            variables=variables,
            headers=headers,
            max_retries=max_retries)
        if not result.ok:
            logger.exception(
                "shopify request %s", format_args(
                    query, {
                        'variables': variables,
                        'headers': headers,
                        'max_retries': max_retries,
                        }, logger.isEnabledFor(logging.DEBUG)))
            msg = []
            if result.log and result.log.detail:
                msg.append(result.log.detail)
            for log in result.http_logs:
                if log.detail:
                    msg.append(log.detail)
            raise GraphQLException("\n".join(msg))
        if user_errors:
            names = user_errors.split('.')
            errors = result.data
            for name in names:
                errors = getattr(errors, name, None)
                if errors is None:
                    break
            if errors:
                raise GraphQLException("\n".join(
                        f"{e['field']}: {e['message']}" for e in errors))
        return result

    def shopify_shop(self, fields):
        result = self.shopify_request(QUERY_SHOP % {
                'fields': graphql.selection(fields),
                })
        return result.data['shop']

    def shopify_shop_locales(self, fields):
        result = self.shopify_request(
            QUERY_SHOP_LOCALES % {
                'fields': graphql.selection(fields),
                })
        return result.data['shopLocales']

    def get_payment_journal(self, currency_code, pattern):
        for payment_journal in self.shopify_payment_journals:
            if (payment_journal.journal.currency.code == currency_code
                    and payment_journal.match(pattern)):
                return payment_journal.journal

    def managed_metafields(self):
        return set()

    @classmethod
    def shopify_update_product(
            cls, shops=None, shop_fields=None, shop_locales_fields=None):
        """Update Shopify Products

        The transaction is committed after the creation of each new resource.
        """
        pool = Pool()
        InventoryItem = pool.get('product.shopify_inventory_item')
        transaction = Transaction()
        if shops is None:
            shops = cls.search([
                    ('type', '=', 'shopify'),
                    ])
        shop_fields = graphql.deep_merge(shop_fields or {}, {
                'currencyCode': None,
                'taxesIncluded': None,
                'weightUnit': None
                })
        shop_locales_fields = graphql.deep_merge(shop_locales_fields or {}, {
                'locale': None,
                'primary': None,
                })
        for shop in shops:
            shopify_shop = shop.shopify_shop(shop_fields)
            shop_language = (
                shop.language.code if shop.language
                else transaction.language)
            categories = shop.get_categories()
            products, prices, taxes = shop.get_products(
                key=lambda p: p.template.id)
            sale_prices, sale_taxes = prices, taxes

            context = shop.get_context()
            with Transaction().set_context(_non_sale_price=True):
                sale_context = shop.get_context()
                if context != sale_context:
                    _, prices, taxes = shop.get_products()

            if shopify_shop['currencyCode'] != shop.currency.code:
                raise ShopifyError(gettext(
                        'web_shop_shopify.msg_shop_currency_different',
                        shop=shop.rec_name,
                        shop_currency=shop.currency.code,
                        shopify_currency=shopify_shop['currencyCode']))
            shop_locales = shop.shopify_shop_locales(shop_locales_fields)
            primary_locale = next(
                filter(lambda l: l['primary'], shop_locales))
            if primary_locale['locale'] != shop_language:
                raise ShopifyError(gettext(
                        'web_shop_shopify.msg_shop_locale_different',
                        shop=shop.rec_name,
                        shop_language=shop_language,
                        shopify_primary_locale=primary_locale['locale'],
                        ))

            for category in categories:
                shop._shopify_update_collection(category)

            categories = set(categories)
            inventory_items = dict(
                zip(products, InventoryItem.browse(products)))
            for template, t_products in groupby(
                    products, key=lambda p: p.template):
                t_products = sorted(
                    t_products,
                    key=lambda p: (
                        p.position if p.position is not None else -1,
                        p.id))
                p_inventory_items = [
                    inventory_items[p] for p in t_products]
                p_sale_prices = [sale_prices[p.id] for p in t_products]
                p_sale_taxes = [sale_taxes[p.id] for p in t_products]
                p_prices = [prices[p.id] for p in t_products]
                p_taxes = [taxes[p.id] for p in t_products]
                if shop._shopify_product_is_to_update(
                        template, t_products, p_sale_prices, p_sale_taxes,
                        p_prices, p_taxes):
                    shop._shopify_update_product(
                        shopify_shop, categories, template, t_products,
                        p_inventory_items, p_sale_prices, p_sale_taxes,
                        p_prices, p_taxes)
                    Transaction().commit()

            for category in shop.categories_removed:
                shop._shopify_remove_collection(category)
            shop.categories_removed = []

            products = set(products)
            for product in shop.products_removed:
                template = product.template
                if set(template.products).isdisjoint(products):
                    shop._shopify_remove_product(template)
                product.set_shopify_identifier(shop)
            shop.products_removed = []
        cls.save(shops)

    def _shopify_update_collection(self, category, collection_fields=None):
        if not category.is_shopify_to_update(self):
            return
        collection_fields = graphql.deep_merge(collection_fields or {}, {
                'id': None,
                })
        collection = category.get_shopify(self)
        if collection.get('id') is not None:
            MUTATION = MUTATION_COLLECTION_UPDATE
            output = 'collectionUpdate'
        else:
            MUTATION = MUTATION_COLLECTION_CREATE
            output = 'collectionCreate'
        try:
            result = self.shopify_request(
                MUTATION % {
                    'fields': graphql.selection(collection_fields),
                    }, {
                    'input': collection,
                    },
                user_errors=f'{output}.userErrors')
            collection = result.data[output]['collection']
        except GraphQLException as e:
            raise ShopifyError(gettext(
                    'web_shop_shopify.msg_custom_collection_fail',
                    category=category.rec_name,
                    error=e.message)) from e
        identifier = category.set_shopify_identifier(
            self, gid2id(collection['id']))
        if identifier.to_update:
            identifier.to_update = False
            identifier.save()
        Transaction().commit()
        return collection

    def _shopify_remove_collection(self, category):
        shopify_id = category.get_shopify_identifier(self)
        if shopify_id:
            shopify_id = id2gid('Collection', shopify_id)
            try:
                self.shopify_request(
                    MUTATION_COLLECTION_DELETE, {
                        'input': {
                            'id': shopify_id,
                            }
                        },
                    user_errors='collectionDelete.userErrors')
            except GraphQLException:
                pass
            category.set_shopify_identifier(self)

    def _shopify_product_is_to_update(
            self, template, products, sale_prices, sale_taxes, prices, taxes):
        return (
            template.is_shopify_to_update(self)
            or any(
                prod.is_shopify_to_update(
                    self, sale_price=s_p, sale_tax=s_t, price=p, tax=t)
                for prod, s_p, s_t, p, t in zip(
                    products, sale_prices, sale_taxes, prices, taxes))
            or any(
                prod in self.products_removed for prod in products))

    def _shopify_update_product(
            self, shopify_shop, categories, template, products,
            inventory_items, sale_prices, sale_taxes, prices, taxes,
            product_fields=None):
        pool = Pool()
        Identifier = pool.get('web.shop.shopify_identifier')

        if not template.shopify_uom:
            template.shopify_uom = template.get_shopify_uom()
            template.save()

        product_fields = graphql.deep_merge(product_fields or {}, {
                'id': None,
                'variants(first: 250)': {
                    'nodes': {
                        'id': None,
                        'inventoryItem': {
                            'id': None,
                            },
                        },
                    'pageInfo': {
                        'hasNextPage': None,
                        'endCursor': None,
                        },
                    },
                })

        shopify_product = template.get_shopify(self, categories)
        variants = []
        for position, (
            product, inventory_item,
            sale_price, sale_tax,
            price, tax) in enumerate(zip(
                    products, inventory_items,
                    sale_prices, sale_taxes,
                    prices, taxes),
                start=1):
            self._shopify_check_product(product)
            variant = product.get_shopify(
                self, sale_price, sale_tax, price, tax,
                shop_taxes_included=shopify_shop['taxesIncluded'])
            variant['inventoryItem'] = inventory_item.get_shopify(
                self, shop_weight_unit=shopify_shop['weightUnit'])
            variant['position'] = position
            variants.append(variant)
        shopify_product['variants'] = variants

        if len(variants) == 1 and not shopify_product.get('productOptions'):
            shopify_product.setdefault('productOptions', []).append({
                    'name': "Title",
                    'values': [
                        {'name': "Default Title"},
                        ],
                    })
            variant, = variants
            variant.setdefault('optionValues', []).append({
                    'optionName': "Title",
                    'name': "Default Title",
                    })

        data = {
            'input': shopify_product,
            }
        try:
            result = self.shopify_request(
                MUTATION_PRODUCT_SET % {
                    'fields': graphql.selection(product_fields),
                    }, data,
                user_errors='productSet.userErrors')
            shopify_product = result.data['productSet']['product']

            identifiers = []
            identifier = template.set_shopify_identifier(
                self, gid2id(shopify_product['id']))
            if identifier.to_update:
                identifier.to_update = False
                identifiers.append(identifier)

            shopify_variants = graphql.iterate(
                self, QUERY_PRODUCT_CURSOR % {
                    'fields': graphql.selection({
                            'variants(first: 250, after: $cursor)': (
                                product_fields['variants(first: 250)']),
                            }),
                    },
                {'id': shopify_product['id']}, 'product',
                'variants', shopify_product)

            for (product, inventory_item,
                sale_price, sale_tax,
                price, tax,
                shopify_variant) in zip(
                    products, inventory_items,
                    sale_prices, sale_taxes,
                    prices, taxes,
                    shopify_variants):
                identifier = product.set_shopify_identifier(
                    self, gid2id(shopify_variant['id']))
                update_extra = {
                    'sale_price': sale_price,
                    'sale_tax': sale_tax,
                    'price': price,
                    'tax': tax,
                    }
                if (identifier.to_update
                        or identifier.to_update_extra != update_extra):
                    identifier.to_update = False
                    identifier.to_update_extra = update_extra
                    identifiers.append(identifier)
                identifier = inventory_item.set_shopify_identifier(
                    self, gid2id(shopify_variant['inventoryItem']['id']))
                if identifier.to_update:
                    identifier.to_update = False
                    identifiers.append(identifier)
            Identifier.save(identifiers)
            return shopify_product
        except GraphQLException as e:
            raise ShopifyError(gettext(
                    'web_shop_shopify.msg_product_fail',
                    template=template.rec_name,
                    error=e.message)) from e

    def _shopify_remove_product(self, template):
        shopify_id = template.get_shopify_identifier(self)
        if shopify_id:
            shopify_id = id2gid('Product', shopify_id)
            product = self.shopify_request(
                QUERY_PRODUCT % {
                    'fields': graphql.selection({
                            'id': None,
                            }),
                    }, {'id': shopify_id}).data['product']
            if product:
                try:
                    self.shopify_request(
                        MUTATION_PRODUCT_CHANGE_STATUS, {
                            'productId': shopify_id,
                            'status': 'ARCHIVED',
                            },
                        user_errors='productChangeStatus.userErrors')
                except GraphQLException as e:
                    raise ShopifyError(gettext(
                            'web_shop_shopify.msg_product_fail',
                            template=template.rec_name,
                            error=e.message)) from e

    @classmethod
    def shopify_update_inventory(cls, shops=None):
        """Update Shopify Inventory"""
        pool = Pool()
        Product = pool.get('product.product')
        if shops is None:
            shops = cls.search([
                    ('type', '=', 'shopify'),
                    ])
        for shop in shops:
            for shop_warehouse in shop.shopify_warehouses:
                if not (location_id := shop_warehouse.shopify_id):
                    continue
                location_id = id2gid('Location', location_id)
                with Transaction().set_context(
                        shop.get_context(),
                        **shop_warehouse.get_shopify_inventory_context()):
                    products = Product.browse([
                            p for p in shop.products if p.shopify_uom])
                    shop._shopify_update_inventory(products, location_id)

    def _shopify_update_inventory(self, products, location_id):
        pool = Pool()
        InventoryItem = pool.get('product.shopify_inventory_item')
        inventory_items = InventoryItem.browse(products)

        input = {
            'ignoreCompareQuantity': True,
            'name': 'available',
            'reason': 'other',
            }
        input['quantities'] = quantities = []

        def set_quantities():
            try:
                for quantity in quantities:
                    self.shopify_request(
                        MUTATION_INVENTORY_ACTIVATE, {
                            'inventoryItemId': quantity['inventoryItemId'],
                            'locationId': quantity['locationId'],
                            },
                        user_errors='inventoryActivate.userErrors')
                self.shopify_request(
                    MUTATION_INVENTORY_SET_QUANTITIES, {
                        'input': input,
                        },
                    user_errors='inventorySetQuantities.userErrors')
            except GraphQLException as e:
                raise ShopifyError(gettext(
                        'web_shop_shopify.msg_inventory_set_fail',
                        error=e.message)) from e
            quantities.clear()

        for product, inventory_item in zip(products, inventory_items):
            inventory_item_id = inventory_item.get_shopify_identifier(self)
            if inventory_item_id:
                inventory_item_id = id2gid('InventoryItem', inventory_item_id)
                quantity = {
                    'inventoryItemId': inventory_item_id,
                    'locationId': location_id,
                    'quantity': int(product.shopify_quantity),
                    }
                quantities.append(quantity)
            if len(quantities) >= 250:
                set_quantities()
        if quantities:
            set_quantities()

    @classmethod
    def shopify_fetch_order(cls, shops=None):
        """Fetch new Shopify Order"""
        pool = Pool()
        Sale = pool.get('sale.sale')
        Payment = pool.get('account.payment')
        context = Transaction().context
        if shops is None:
            shops = cls.search([
                    ('type', '=', 'shopify'),
                    ])
        fields = {'nodes': Sale.shopify_fields()}
        cls.lock(shops)
        for shop in shops:
            last_sales = Sale.search([
                    ('web_shop', '=', shop.id),
                    ], order=[('shopify_identifier_signed', 'DESC')], limit=1)
            if last_sales:
                last_sale, = last_sales
                last_order_id = last_sale.shopify_identifier
            else:
                last_order_id = ''
            if pool.test and 'shopify_orders' in context:
                query = ' OR '.join(
                    f'id:{id}' for id in context['shopify_orders'])
            elif last_order_id:
                query = f'-status:cancelled AND id:>{last_order_id}'
            else:
                query = '-status:cancelled'
            orders = shop.shopify_request(
                QUERY_ORDERS % {
                    'query': query,
                    'fields': graphql.selection(fields),
                    }).data['orders']
            sales = []
            for order in orders['nodes']:
                sales.append(Sale.get_from_shopify(shop, order))
            Sale.save(sales)
            for sale, order in zip(sales, orders['nodes']):
                total_price = Decimal(
                    order['currentTotalPriceSet']['presentmentMoney'][
                        'amount'])
                sale.shopify_tax_adjustment = (
                    total_price - sale.total_amount)
            Sale.save(sales)
            to_quote = [
                s for s in sales if s.party != s.web_shop.guest_party]
            if to_quote:
                Sale.quote(to_quote)
            for sale, order in zip(sales, orders['nodes']):
                if sale.state != 'draft':
                    Payment.get_from_shopify(sale, order)
            Sale.payment_confirm(sales)

    @classmethod
    def shopify_update_order(cls, shops=None):
        """Update existing sale from Shopify"""
        pool = Pool()
        Sale = pool.get('sale.sale')
        if shops is None:
            shops = cls.search([
                    ('type', '=', 'shopify'),
                    ])
        now = dt.datetime.now()
        for shop in shops:
            sales = Sale.search([
                    ('web_shop', '=', shop.id),
                    ('shopify_identifier', '!=', None),
                    ['OR',
                        ('state', 'in',
                            ['quotation', 'confirmed', 'processing']),
                        ('create_date', '>=', now - EDIT_ORDER_DELAY),
                        ],
                    ])
            for sub_sales in grouped_slice(sales, count=20):
                cls._shopify_update_order(shop, list(sub_sales))
                cls.__queue__._shopify_update_order(
                    shop, [s.id for s in sub_sales])

    @classmethod
    def _shopify_update_order(cls, shop, sales):
        pool = Pool()
        Sale = pool.get('sale.sale')
        sales = Sale.browse(sales)
        assert shop.type == 'shopify'
        assert all(s.web_shop == shop for s in sales)
        fields = {'nodes': Sale.shopify_fields()}
        query = ' OR '.join(
            f'id:{s.shopify_identifier}' for s in sales)
        orders = shop.shopify_request(
            QUERY_ORDERS % {
                'query': query,
                'fields': graphql.selection(fields),
                }).data['orders']
        id2order = {gid2id(o['id']): o for o in orders['nodes']}

        to_update = []
        orders = []
        for sale in sales:
            try:
                order = id2order[sale.shopify_identifier]
            except KeyError:
                continue
            to_update.append(sale)
            orders.append(order)
        cls.shopify_update_sale(to_update, orders)

    @classmethod
    def shopify_update_sale(cls, sales, orders):
        """Update sales based on Shopify orders"""
        pool = Pool()
        Amendment = pool.get('sale.amendment')
        Payment = pool.get('account.payment')
        Sale = pool.get('sale.sale')
        assert len(sales) == len(orders)
        to_update = {}
        states_to_restore = defaultdict(list)
        for sale, order in zip(sales, orders):
            assert sale.shopify_identifier == gid2id(order['id'])
            shop = sale.web_shop
            sale = Sale.get_from_shopify(shop, order, sale=sale)
            if sale._changed_values():
                sale.untaxed_amount_cache = None
                sale.tax_amount_cache = None
                sale.total_amount_cache = None
                sale.shopify_tax_adjustment = None
                to_update[sale] = order
                states_to_restore[sale.state].append(sale)
        Sale.write(list(to_update.keys()), {'state': 'draft'})
        with Transaction().set_context(_log=True):
            Sale.save(to_update.keys())
        for state, state_sales in states_to_restore.items():
            Sale.write(list(state_sales), {'state': state})
        for sale, order in to_update.items():
            current_total_price = Decimal(
                order['currentTotalPriceSet']['presentmentMoney']['amount'])
            sale.shopify_tax_adjustment = (
                current_total_price - sale.total_amount)
        Sale.save(to_update.keys())
        Sale.store_cache(to_update.keys())
        Amendment._clear_sale(to_update.keys())
        to_process, to_quote = [], []
        for sale in to_update:
            if sale.party == sale.web_shop.guest_party:
                continue
            if (sale.payment_amount_authorized >= sale.amount_to_pay
                    or sale.state in {'processing', 'done'}):
                to_process.append(sale)
            else:
                to_quote.append(sale)
        if to_process:
            Sale.__queue__.process(to_process)
        if to_quote:
            # Use write because there is no transition
            Sale.write(to_quote, {'state': 'quotation'})
            cls.log(to_quote, 'transition', 'state:quotation')

        for sale, order in zip(sales, orders):
            if sale.state != 'draft':
                Payment.get_from_shopify(sale, order)
        Sale.payment_confirm(sales)

    @classmethod
    def check_modification(cls, mode, shops, values=None, external=False):
        pool = Pool()
        Warning = pool.get('res.user.warning')
        super().check_modification(
            mode, shops, values=values, external=external)
        if (mode == 'write'
                and external
                and values.keys() & {
                    'shopify_shop_name',
                    'shopify_access_token',
                    'shopify_webhook_shared_secret'}):
            warning_name = Warning.format('shopify_credential', shops)
            if Warning.check(warning_name):
                raise ShopifyCredentialWarning(
                    warning_name,
                    gettext('web_shop_shopify'
                        '.msg_shopidy_credential_modified'))


class Shop_Image(metaclass=PoolMeta):
    __name__ = 'web.shop'

    def _shopify_product_is_to_update(
            self, template, products, sale_prices, sale_taxes, prices, taxes):
        return (
            super()._shopify_product_is_to_update(
                template, products, sale_prices, sale_taxes, prices, taxes)
            or any(
                i.is_shopify_to_update(self) for i in template.shopify_images))

    def _shopify_update_product(
            self, shopify_shop, categories, template, products,
            inventory_items, sale_prices, sale_taxes, prices, taxes,
            product_fields=None):
        pool = Pool()
        Identifier = pool.get('web.shop.shopify_identifier')

        product_fields = (
            product_fields.copy() if product_fields is not None else {})
        product_fields = graphql.deep_merge(product_fields or {}, {
                'media(first: 250)': {
                    'nodes': {
                        'id': None,
                        'mediaContentType': None,
                        },
                    'pageInfo': {
                        'hasNextPage': None,
                        'endCursor': None,
                        },
                    },
                })
        shopify_product = super()._shopify_update_product(
            shopify_shop, categories, template, products, inventory_items,
            sale_prices, sale_taxes, prices, taxes,
            product_fields=product_fields)

        try:
            shopify_media = graphql.iterate(
                self, QUERY_PRODUCT_CURSOR % {
                    'fields': graphql.selection({
                            'media(first: 250, after: $cursor)': (
                                product_fields['media(first: 250)']),
                            }),
                    },
                {'id': shopify_product['id']}, 'product',
                'media', shopify_product)

            identifiers = []
            for image, shopify_image in zip(
                    template.shopify_images,
                    filter(lambda m: m['mediaContentType'] == 'IMAGE',
                        shopify_media)):
                identifier = image.set_shopify_identifier(
                    self, gid2id(shopify_image['id']))
                if identifier.to_update:
                    identifier.to_update = False
                    identifiers.append(identifier)
            Identifier.save(identifiers)
            return shopify_product
        except GraphQLException as e:
            raise ShopifyError(gettext(
                    'web_shop_shopify.msg_product_fail',
                    template=template.rec_name,
                    error=e.message)) from e


class ShopShopifyIdentifier(IdentifierMixin, ModelSQL, ModelView):
    __name__ = 'web.shop.shopify_identifier'

    record = fields.Reference("Record", 'get_records', required=True)
    web_shop = fields.Many2One(
        'web.shop', "Web Shop", required=True, ondelete='CASCADE')
    to_update = fields.Boolean("To Update")
    to_update_extra = fields.Dict(None, "To Update Extra")

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.shopify_identifier_signed.states = {
            'required': True,
            }
        t = cls.__table__()
        cls._sql_constraints += [
            ('web_shop_record_unique',
                Unique(t, t.record, t.web_shop),
                'web_shop_shopify.msg_identifier_record_web_shop_unique'),
            ]
        cls._buttons.update({
                'set_to_update': {},
                })

    @classmethod
    def __register__(cls, module):
        table_h = cls.__table_handler__(module)
        super().__register__(module)
        # Migration from 7.6: replace record_web_shop_unique
        table_h.drop_constraint('record_web_shop_unique')

    @classmethod
    def get_records(cls):
        pool = Pool()
        Model = pool.get('ir.model')
        get_name = Model.get_name
        models = (klass.__name__ for _, klass in pool.iterobject()
            if issubclass(klass, IdentifiersMixin))
        return [(m, get_name(m)) for m in models]

    @classmethod
    def set_to_update(cls, identifiers):
        cls.write(identifiers, {'to_update': True})

    @property
    def shopify_resource(self):
        if self.record.__name__ == 'product.product':
            product_identifier = self.record.template.get_shopify_identifier(
                self.web_shop)
            resource = f'products/{product_identifier}/variants'
        else:
            resource = {
                'party.party': 'customers',
                'product.category': 'collections',
                'product.template': 'products',
                }.get(self.record.__name__)
        return resource


class Shop_Warehouse(ModelView, metaclass=PoolMeta):
    __name__ = 'web.shop-stock.location'

    shopify_stock_skip_warehouse = fields.Boolean(
        "Only storage zone",
        help="Check to use only the quantity of the storage zone.")
    shopify_id = fields.Selection(
        'get_shopify_locations', "Shopify ID")
    _shopify_locations_cache = Cache(
        __name__ + '.get_shopify_locations',
        duration=config.getint(
            'web_shop_shopify', 'locations_cache', default=15 * 60),
        context=False)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.__access__.add('shop')
        t = cls.__table__()
        cls._sql_constraints += [
            ('shopify_id_unique',
                Unique(t, t.shopify_id),
                'web_shop_shopify.msg_location_id_unique'),
            ]

    @fields.depends(
        'shop',
        '_parent_shop.shopify_shop_name',
        '_parent_shop.shopify_access_token')
    def get_shopify_locations(self):
        locations = [(None, "")]
        session = attrgetter('shopify_shop_name', 'shopify_access_token')
        if self.shop and all(session(self.shop)):
            locations_cache = self._shopify_locations_cache.get(self.shop.id)
            if locations_cache is not None:
                return locations_cache
            try:
                for location in graphql.iterate(
                        self.shop, QUERY_LOCATIONS, {}, 'locations'):
                    locations.append(
                        (str(gid2id(location['id'])), location['name']))
                locations = self._shopify_locations_cache.set(
                    self.shop.id, locations)
            except GraphQLException:
                pass
        return locations

    def get_shopify_inventory_context(self):
        return {
            'locations': [self.warehouse.id],
            'stock_skip_warehouse': self.shopify_stock_skip_warehouse,
            'with_childs': True,
            }


class Shop_Attribute(metaclass=PoolMeta):
    __name__ = 'web.shop-product.attribute'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        domain = [
            ('type', '!=', 'shopify'),
            ]
        if cls.shop.domain:
            cls.shop.domain = [cls.shop.domain, domain]
        else:
            cls.shop.domain = domain


class ShopShopifyPaymentJournal(
        sequence_ordered(), MatchMixin, ModelSQL, ModelView):
    __name__ = 'web.shop.shopify_payment_journal'

    shop = fields.Many2One(
        'web.shop', "Shop", required=True, ondelete='CASCADE',
        domain=[
            ('type', '=', 'shopify'),
            ])
    gateway = fields.Char(
        "Gateway",
        help="The payment gateway name for which the journal must be used.")
    journal = fields.Many2One(
        'account.payment.journal', "Journal", required=True,
        domain=[
            ('process_method', '=', 'shopify'),
            ])

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

# TODO: add wizard to export translations
