import re, json, requests, unicodedata
from generic.gn_api     import gn_api
from generic.gn_request import Request
from generic.gn_graphql import GraphQL

class shopify(gn_api):

    def __init__(self, argv: list):
        self.db = False

        # Carrega empresa/filial e tokens da TBTOKEN via gn_api.setup()
        setup = self.setup(argv)

        # Credenciais OAuth gravadas na TBTOKEN
        # Chaves esperadas em TBTOKEN.nome_token:
        #   'Servidor'     → minha-loja.myshopify.com
        #   'ClientID'     → client_id do Dev Dashboard
        #   'ClientSecret' → client_secret do Dev Dashboard
        self.shop          = self.get_token_data('Servidor')
        self.client_id     = self.get_token_data('ClientID')
        self.client_secret = self.get_token_data('ClientSecret')

        # Obtém um access_token fresco (expira em 24h)
        access_token = self._get_access_token()

        # Objeto de request principal — usado pelo gn_api em todas as rotas
        base_url = f"https://{self.shop}/admin/api/2026-10/"

        self.shopify_request = Request(
            base_url = base_url,
            headers  = {
                'Content-Type'           : 'application/json',
                'X-Shopify-Access-Token' : access_token
            }
        )

        # Cliente GraphQL — a REST Admin API não expõe tudo (o CPF/CNPJ do
        # checkout, por exemplo, só existe em order.localizationExtensions).
        # Reaproveita o mesmo Request: mesma base_url, mesmo access token.
        self.shopify_graphql = GraphQL(
            request = self.shopify_request,
            rote    = 'graphql.json',
            logger  = self,
            debug   = self.debug
        )

        apiKey = self.get_token_data('Intelipost')
        baseUrl = "https://api.intelipost.com.br/api/v1/"

        self.intelipost_request = Request(
            base_url = baseUrl,
            params   = {},
            verify= False,
            headers={
                'api-key': f"{apiKey}",
                'platform': 'SOFTDIB',
                "platform-version": '1.0', 
                'Content-Type': 'application/json'
            }
        )

        # Inicializa o framework (bootstrap → load_api_map → handle_api_map)
        super(shopify, self).__init__()
        print('Shopify - Fim')


    # ================================================================ #
    # ----------------------- AUTENTICAÇÃO --------------------------- #
    # ================================================================ #

    def _get_access_token(self) -> str:
        """
        Solicita um novo access_token via client_credentials.
        Tokens expiram em 24h — sempre renovamos ao iniciar a execução.
        """
        url = f"https://{self.shop}/admin/oauth/access_token"

        response = requests.post(
            url,
            headers = { 'Content-Type': 'application/x-www-form-urlencoded' },
            data    = {
                'grant_type'    : 'client_credentials',
                'client_id'     : self.client_id,
                'client_secret' : self.client_secret
            }
        )

        if response.status_code != 200:
            self.createLog('401', f'Falha ao obter access_token Shopify: {response.text}')
            exit()

        token = response.json().get('access_token')
        self.createLog('000', 'Access token Shopify obtido com sucesso')
        return token
    
    # ================================================================ #
    # -------------------- PREPARE SEND — GLOBAL -------------------- #
    # ================================================================ #
 
    def prepare_send(self, data):
        """
        Sobrescreve o prepare_send do gn_api para garantir que
        todas as rotas Shopify REST terminam com .json.
        Sem isso a Shopify redireciona para /admin/auth/login.
        """
        super().prepare_send(data)
 
        if not self.current_rote.endswith('.json'):
            self.current_rote = self.current_rote.rstrip('/') + '.json'

    # ================================================================ #
    # -------------------- THROTTLING / RATE LIMIT ------------------- #
    # ================================================================ #

    def throttling_rules(self, req):
        """
        Trata o rate limit da Shopify REST: 40 req/min.
        Quando recebe 429, aguarda o tempo indicado no header Retry-After.
        O gn_api.throttling() lê o retorno e dá sleep automaticamente.
        """
        if req.status_code == 429:
            retry_after = float(req.headers.get('Retry-After', 2))
            self.createLog('429', f'Rate limit atingido. Aguardando {retry_after}s...')
            return retry_after  # gn_api vai dar sleep e retentar

        return False  # sem throttling, segue normal


    # ================================================================ #
    # --------- TRATAMENTO CUSTOM SQL — ROTA: products --------------- #
    # ================================================================ #

    def valor_produto(self, data):
        """
        Produtos vendidos por metro (MT) ou metro quadrado (M2) têm
        o valor armazenado multiplicado por 100 no sistema interno.
        Este método reverte a conversão antes de enviar para Shopify.
        Reutiliza a mesma lógica do lojainteg.py.
        """
        field = data.get('dbfield')
        return f"(SELECT IF(TBPRODUTO.NMUNIDADE IN ('MT','M2'), {field} / 100, {field}))"

    def preco_promocional_shopify(self, data):
        """
        Na Shopify:
          - price             = preço promocional (o que o cliente paga)
          - compare_at_price  = preço original "riscado" na vitrine
        Se VLPROMOCIONAL = 0, enviamos NULL para não exibir preço riscado.
        """
        field = data.get('dbfield')
        return f"(SELECT IF({field} > 0, {field}, NULL))"
    
    # ================================================================ #
    # ----------- CUSTOM SQL — ROTA: inventory_levels/set ------------ #
    # ================================================================ #
 
    def quantidade_estoque_shopify(self, data):
        """
        Converte a quantidade para a Shopify:
          - Produtos MT/M2: multiplica por 100 (o sistema interno
            divide por 100 no preço, então para unidades fazemos o inverso)
          - Outros: usa o valor direto
        Garante que o resultado seja inteiro (FLOOR).
        """
        field = data.get('dbfield')
        return f"(SELECT FLOOR(IF(TBPRODUTO.NMUNIDADE IN ('MT','M2'), {field} * 100, {field})))"

    # ================================================================ #
    # --------- BEFORESEND — ROTA: custom_collections --------------- #
    # ================================================================ #

    def beforeSend_custom_collections(self, data):
        """
        Trata a hierarquia de categorias antes de enviar para a Shopify.

        A Shopify não possui coleções aninhadas nativamente via REST.
        Adotamos a seguinte estratégia:

          - CDSUBGRUPO = 0  → categoria raiz, 'colecao_pai_id' fica vazio
          - CDSUBGRUPO > 0  → subcategoria, buscamos o ID Shopify da
                              coleção pai via TBRELACIONAID e gravamos
                              no metafield 'colecao_pai_id' para que o
                              front-end/tema possa usar a hierarquia.

        Campos auxiliares (cd_grupo, cd_subgrupo, id_externo) são removidos
        do payload final pois não fazem parte da API da Shopify.
        """
        colecao = data.get('custom_collection', {})
        cd_subgrupo = int(self.current_data.get('cd_subgrupo', 0))
        cd_grupo    = self.current_data.get('cd_grupo', '')

        if cd_subgrupo == 0:
            # Categoria raiz: remove o metafield de pai pois não tem
            colecao['metafields'] = [
                mf for mf in colecao.get('metafields', [])
                if mf.get('key') != 'colecao_pai_id'
            ]
        else:
            # Subcategoria: traduz o cd_grupo para o ID da coleção pai na Shopify
            # A chave do pai é sempre CDGRUPO + '.0' (raiz do mesmo grupo)
            chave_pai       = f"{cd_grupo}.0"
            id_shopify_pai  = self.translate(id=chave_pai, table='TBGRUPO', reverse=False)

            # Atualiza o metafield com o ID real da coleção pai
            for mf in colecao.get('metafields', []):
                if mf.get('key') == 'colecao_pai_id':
                    mf['value'] = str(id_shopify_pai) if id_shopify_pai else ''

            # Remove o metafield de pai se não encontrou o ID (evita erro 422)
            if not id_shopify_pai:
                self.createLog(
                    '400',
                    f'Coleção pai não encontrada em TBRELACIONAID para chave: {chave_pai}'
                )
                colecao['metafields'] = [
                    mf for mf in colecao.get('metafields', [])
                    if mf.get('key') != 'colecao_pai_id'
                ]

        # Remove metafields sem valor para evitar erro 422 da Shopify
        colecao['metafields'] = [
            mf for mf in colecao.get('metafields', [])
            if mf.get('value') not in [None, 'None', '']
        ]

        # Limpa campos auxiliares que não pertencem ao payload da Shopify
        for campo_aux in ['cd_grupo', 'cd_subgrupo', 'id_externo']:
            colecao.pop(campo_aux, None)

        data['custom_collection'] = colecao
        return data


    # ================================================================ #
    # --------- BEFORESEND — ROTA: products -------------------------- #
    # ================================================================ #

    def beforeSend_products(self, data):
        if self.current_method == 'PUT':
            self.ignore_request = True

            return data
    
        """
        Ajustes finais no payload antes de enviar o produto para Shopify.

        1. Normaliza compare_at_price: string 'None' → None → JSON null
        2. Remove metafields com value vazio para evitar erro 422
        3. Limpa tags removendo entradas sem valor (ex: 'NCM:,UNIDADE:')
        """
        produto = data.get('product', {})

        # 1. Normaliza compare_at_price
        for variant in produto.get('variants', []):
            cap = variant.get('compare_at_price')
            if cap in [None, 'None', '', '0', '0.00']:
                variant['compare_at_price'] = None

        # 2. Remove metafields sem valor
        produto['metafields'] = [
            mf for mf in produto.get('metafields', [])
            if mf.get('value') not in [None, 'None', '', '0']
        ]

        # 3. Limpa tags vazias
        tags_limpos = ','.join([
            t for t in produto.get('tags', '').split(',')
            if ':' in t and t.split(':')[1].strip() not in ['', 'None']
        ])
        produto['tags'] = tags_limpos

        data['product'] = produto

        return data


    # ================================================================ #
    # --------- AFTERSEND — ROTA: products --------------------------- #
    # ================================================================ #

    def afterSend_products(self, data):
        """
        Após criar/atualizar um produto com sucesso executa duas ações:
 
        1. Associa o produto à coleção via /collects.json
        2. Salva o inventory_item_id em TBRELACIONAID com
           NMTABELA = 'TBPRODUTO_INVENTORY' para ser usado
           pela rota inventory_levels/set como chave de tradução.
        """
        produto_id  = self.current_data.get(self.application_id)
        softdib_id  = self.current_data.get('softdib_id')
 
        # --- 1. Associa à coleção ---
        categoria_id = self.current_data.get('categoria_id')
        if categoria_id and produto_id:
            response = self.throttling(
                method = 'POST',
                rote   = 'collects.json',
                json   = {
                    'collect': {
                        'product_id'    : produto_id,
                        'collection_id' : categoria_id
                    }
                }
            )
            if not response.ok and response.status_code != 422:
                self.createLog(
                    response.status_code,
                    f'Falha ao associar produto {produto_id} à coleção {categoria_id}: {response.text}'
                )
 
        # --- 2. Salva inventory_item_id ---
        # Busca o produto na Shopify para obter o inventory_item_id do variant
        if not produto_id:
            return
 
        res = self.throttling(
            method = 'GET',
            rote   = f'products/{produto_id}.json'
        )
 
        if not res.ok:
            self.createLog(res.status_code, f'Falha ao buscar produto {produto_id} para salvar inventory_item_id')
            return
 
        produto_shopify  = res.json().get('product', {})
        variants         = produto_shopify.get('variants', [])
 
        if not variants:
            self.createLog('400', f'Produto {produto_id} sem variants — inventory_item_id não salvo')
            return
 
        inventory_item_id = variants[0].get('inventory_item_id')
        variant_id        = variants[0].get('id')

        # Cache para a rota variants/price: no cadastro a Shopify devolve os
        # dois ids de uma vez, entao o preco sai na proxima execucao da rota
        # sem gastar consulta. HASHMD5 = None marca como pendente de preco.
        if variant_id:
            self._gravar_relacao('TBPRODUTO_PRODUCT', softdib_id, produto_id)
            self._gravar_relacao('TBPRODUTO_PRECO',   softdib_id, variant_id)
 
        if not inventory_item_id:
            return
 
        # Grava em TBRELACIONAID para a rota de inventário traduzir
        # IDSOFTDIB = CDPRODUTO | IDAPI = inventory_item_id | NMTABELA = TBPRODUTO_INVENTORY
        self.relatement({
            'NMNOMEAPI'         : self.apiName,
            'NMTABELA'          : 'TBPRODUTO_INVENTORY',
            'IDSOFTDIB'         : softdib_id,
            'IDAPI'             : str(inventory_item_id),
            'DTULTIMAALTERACAO' : 'now()',
            'HASHMD5'           : None
        })
 
        self.createLog('200', f'inventory_item_id {inventory_item_id} salvo para produto {softdib_id}')

        

    # ================================================================ #
    # ----------- BEFORESEND — ROTA: inventory_levels/set ------------ #
    # ================================================================ #
 
    def beforeSend_inventory_levels_set(self, data):
        """
        Preenche o location_id vindo do TBTOKEN e valida o inventory_item_id.
 
        Se inventory_item_id não foi encontrado (produto ainda não enviado
        ou afterSend_products falhou), cancela o envio desta linha.
        """
        # Força sempre POST independente do request_method do MD5
        self.current_method = 'POST'

        inventory = data.get('inventory_levels_set', data)  # flexível para qualquer estrutura
 
        # Busca o location_id configurado no banco
        location_id = self.get_token_data('LocationID')
 
        if not location_id:
            self.createLog('400', 'LocationID não encontrado em TBTOKEN — configure antes de rodar inventário')
            self.atualWs['send'] = False
            return data
 
        inventory['location_id'] = int(location_id)
 
        # Valida inventory_item_id — se vier False/None o produto ainda não foi enviado
        inventory_item_id = inventory.get('inventory_item_id')
        if not inventory_item_id or inventory_item_id in [False, 'False', 'None', '']:
            softdib_id = self.current_data.get('softdib_id', '')
            self.createLog(
                '400',
                f'inventory_item_id não encontrado para {softdib_id} — rode a rota products primeiro'
            )
            # Sinaliza para o gn_api pular o envio desta linha
            self.atualWs['send'] = False
            return data
 
        inventory['inventory_item_id'] = int(inventory_item_id)
 
        # Restaura send para True (pode ter sido desativado em linha anterior)
        self.atualWs['send'] = True
 
        return data
    
    # ================================================================ #
    # --------- ROTA: inventory_items/cost --------------------------- #
    # ================================================================ #

    def beforeSend_inventory_items_cost(self, data):
        """
        Monta a rota correta com o inventory_item_id na URL.
        PUT /inventory_items/{id}.json

        O gn_api monta a rota base como 'inventory_items/cost'
        e aqui substituímos pelo ID real do inventory_item.
        """
        inventory_item_id = data.get('inventory_item_id')

        if not inventory_item_id or str(inventory_item_id) in ['False', 'None', '']:
            self.createLog(
                '400',
                f'inventory_item_id não encontrado para {self.current_data.get("softdib_id")} '
                f'— rode products/sync_inventory primeiro'
            )
            self.atualWs['send'] = False
            return data

        # Substitui a rota base pelo endpoint real com o ID
        self.current_rote = f"inventory_items/{inventory_item_id}.json"
        self.atualWs['send'] = True

        # Remove inventory_item_id do payload — ele vai na URL, não no body
        data.pop('inventory_item_id', None)

        # Encapsula no formato esperado pela Shopify
        custo = data.pop('custo', '0')
        data['inventory_item'] = { 'cost': str(custo) }

        return data
    
    # ================================================================ #
    # --------- ROTA: variants/price --------------------------------- #
    # --------- Atualiza SOMENTE o preco, via GraphQL ---------------- #
    # ================================================================ #

    # A rota products so envia no cadastro: o beforeSend_products cancela o
    # PUT de proposito, porque os dados cadastrais do produto sao mantidos
    # na Shopify. O que continua saindo do Softdib e preco (aqui) e estoque
    # (inventory_levels/set).
    #
    # O envio e por GraphQL e nao por PUT /variants/{id}.json porque os
    # endpoints REST de product e variant estao deprecated desde a versao
    # 2024-04 e a REST Admin API e legada desde 01/10/2024 —
    # productVariantsBulkUpdate e o caminho suportado.
    #
    # Como o gn_api so sabe falar REST, a chamada e feita aqui dentro e o
    # send() nativo e cancelado com ignore_request, mesmo mecanismo do
    # beforeSend_orders_nota_fiscal.
    #
    # Chaves na TBRELACIONAID:
    #   TBPRODUTO          -> variant_id  (ja existente, gravado pelo sync)
    #   TBPRODUTO_PRODUCT  -> product_id  (cache; a mutation exige o produto)
    #   TBPRODUTO_PRECO    -> controle de MD5 desta rota; o IDAPI guarda o
    #                         variant_id resolvido, servindo de cache proprio
    # Busca por NÚMERO do pedido (campo 'name' da Shopify, ex: #1021) — que é
    # o que a integração guarda em CDPEDIDOEXTERNO. O id interno da Shopify
    # (gid) NÃO é o número; por isso resolvemos via query 'name:#...', o mesmo
    # filtro que a importação de pedidos já usa.
    QUERY_FO_POR_NUMERO = """
        query fulfillmentOrdersPorNumero($query: String!) {
            orders(first: 1, query: $query) {
                edges { node {
                    id
                    name
                    displayFulfillmentStatus
                    displayFinancialStatus
                    lineItems(first: 20) {
                        edges { node { requiresShipping quantity unfulfilledQuantity } }
                    }
                    fulfillmentOrders(first: 20) {
                        edges { node { id status } }
                    }
                } }
            }
        }
    """

    # Busca direta por ID interno (gid), para quando já se tem o id real.
    QUERY_FO_POR_ID = """
        query fulfillmentOrdersPorId($id: ID!) {
            order(id: $id) {
                id
                name
                displayFulfillmentStatus
                displayFinancialStatus
                fulfillmentOrders(first: 20) {
                    edges { node { id status } }
                }
            }
        }
    """

    MUTATION_FO_REPORTAR_PROGRESSO = """
        mutation marcarProgresso($id: ID!, $report: FulfillmentOrderReportProgressInput) {
            fulfillmentOrderReportProgress(id: $id, progressReport: $report) {
                fulfillmentOrder { id status }
                userErrors      { field message }
            }
        }
    """

    MUTATION_PRECO_VARIANT = """
        mutation atualizaPrecoVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
            productVariantsBulkUpdate(productId: $productId, variants: $variants) {
                productVariants { id price }
                userErrors      { field message }
            }
        }
    """

    QUERY_VARIANT_POR_ID = """
        query variantPorId($id: ID!) {
            productVariant(id: $id) { id product { id } }
        }
    """

    QUERY_VARIANT_POR_SKU = """
        query variantPorSku($busca: String!) {
            productVariants(first: 1, query: $busca) {
                edges { node { id product { id } } }
            }
        }
    """

    def beforeSend_variants_price(self, data):
        """
        Atualiza o preco do variant na Shopify.

        A mutation exige o productId, que nao existe na TBRELACIONAID —
        so o variant foi guardado. Ele e resolvido uma vez por produto
        (ver _resolver_ids_shopify) e fica em cache na propria tabela.
        """
        # Quem envia somos nos, via GraphQL — cancela o send() REST do gn_api.
        self.ignore_request = True

        softdib_id = self.current_data.get('softdib_id')
        preco      = self.current_data.get('preco')

        if preco in [None, '', 'None']:
            self.createLog(
                '400',
                f'Preco {softdib_id}: sem valor em TBTABPRECOPRODUTO (CDTABELA=SHO) — pulado'
            )
            return data

        product_id, variant_id = self._resolver_ids_shopify()

        if not product_id or not variant_id:
            # _resolver_ids_shopify ja logou o motivo
            return data

        resultado = self.shopify_graphql.mutate(
            self.MUTATION_PRECO_VARIANT,
            {
                'productId' : f'gid://shopify/Product/{product_id}',
                'variants'  : [{
                    'id'    : f'gid://shopify/ProductVariant/{variant_id}',
                    'price' : str(preco)
                }]
            },
            result_path = 'productVariantsBulkUpdate'
        )

        # mutate() devolve {} e ja loga o motivo (errors[] ou userErrors[]).
        # Sem gravar o hash, o produto volta a ser selecionado na proxima
        # execucao — que e exatamente o que queremos quando falha.
        if not resultado:
            self.createLog('422', f'Preco {softdib_id}: falha ao atualizar variant {variant_id}')
            return data

        # Esta rota nao passa pelo send() nativo, entao o response_code_201 —
        # unico lugar do gn_api que grava o HASHMD5 — nunca roda. Gravamos
        # aqui, senao o preco seria reenviado em toda execucao.
        self._gravar_relacao('TBPRODUTO_PRECO', softdib_id, variant_id,
                             hash_md5 = self.current_data.get('hash_md5'))

        self.createLog('200', f'Preco {softdib_id}: variant {variant_id} atualizado para {preco}')

        return data

    # ---------------------------------------------------------------- #

    def _resolver_ids_shopify(self):
        """
        Devolve (product_id, variant_id) numericos, ou (None, None).

        Ordem de busca do variant, da mais barata para a mais cara:
          1. TBPRODUTO_PRECO — cache desta rota (vem no shopify_id, porque
             e o alias da rota no map)
          2. TBPRODUTO       — onde o products/sync grava o variant
          3. consulta por SKU na Shopify

        O passo 3 existe porque a TBPRODUTO nem sempre guarda um variant: a
        rota products, no POST, grava o product_id nessa mesma chave. Quando
        cai nele, o id resolvido e gravado em TBPRODUTO_PRECO e as execucoes
        seguintes nao repetem a consulta.

        O product_id nunca foi guardado no Softdib, entao sai do cache
        TBPRODUTO_PRODUCT ou da mesma consulta que resolve o variant.
        """
        softdib_id   = self.current_data.get('softdib_id')
        product_id   = self.current_data.get('product_id')
        variant_cache = self.current_data.get(self.application_id)
        variant_id   = variant_cache or self.current_data.get('variant_id')

        # Caminho feliz: os dois ja estao em cache, nenhuma consulta extra
        if self._id_relacionado(product_id) and self._id_relacionado(variant_id):
            return str(product_id), str(variant_id)

        no = {}

        # --- Tem o variant, falta o produto: pergunta pelo proprio variant --
        if self._id_relacionado(variant_id):
            retorno = self.shopify_graphql.run(
                self.QUERY_VARIANT_POR_ID,
                { 'id': f'gid://shopify/ProductVariant/{variant_id}' }
            )
            no = (retorno or {}).get('productVariant') or {}

        # --- Sem variant confiavel: procura pelo SKU (= CDPRODUTO) ---------
        if not no:
            sku     = self.current_data.get('sku') or softdib_id
            retorno = self.shopify_graphql.run(
                self.QUERY_VARIANT_POR_SKU,
                { 'busca': f'sku:{sku}' }
            )
            # flatten() ja transformou a connection em lista
            variantes = (retorno or {}).get('productVariants') or []
            no        = variantes[0] if variantes else {}

        variant_id = self._id_numerico(no.get('id'))
        product_id = self._id_numerico((no.get('product') or {}).get('id'))

        if not variant_id or not product_id:
            self.createLog(
                '400',
                f'Preco {softdib_id}: variant nao encontrado na Shopify '
                f'— rode products/sync ou confira o SKU'
            )
            return None, None

        # Guarda o que custou consulta. O hash fica de fora: quem grava e o
        # beforeSend, e so depois do preco ter subido de fato.
        self._gravar_relacao('TBPRODUTO_PRODUCT', softdib_id, product_id)

        # So escreve em TBPRODUTO_PRECO quando o cache estava vazio — a linha
        # ja existente carrega o HASHMD5 do ultimo preco enviado, e sobrescreve-la
        # aqui apagaria o controle de alteracao.
        if not self._id_relacionado(variant_cache):
            self._gravar_relacao('TBPRODUTO_PRECO', softdib_id, variant_id)

        return product_id, variant_id

    def _gravar_relacao(self, nmtabela, softdib_id, idapi, hash_md5 = None):
        self.relatement({
            'NMNOMEAPI'         : self.apiName,
            'NMTABELA'          : nmtabela,
            'IDSOFTDIB'         : softdib_id,
            'IDAPI'             : str(idapi),
            'DTULTIMAALTERACAO' : 'now()',
            'HASHMD5'           : hash_md5
        })

    def _id_numerico(self, gid):
        """gid://shopify/ProductVariant/123 -> '123'. Aceita id ja numerico."""
        if not gid:
            return None

        return str(gid).rstrip('/').split('/')[-1] or None

    def _id_relacionado(self, valor) -> bool:
        """
        A traducao via TBRELACIONAID vira subquery no SELECT: quando nao ha
        relacionamento o valor chega como NULL, e dependendo do caminho o
        framework ainda o converte para as strings 'None' / 'False'.
        """
        return valor not in [None, False, '', '0', 0, 'None', 'False']

    # ================================================================ #
    # -------- ROTA: products/sync_inventory ------------------------- #
    # ================================================================ #

    def rote_filter_products_sync_inventory(self):
        """
        Parâmetros para buscar todos os produtos na Shopify.
        limit=250 é o máximo permitido pela REST API.
        fields=id,variants limita o payload — só precisamos do
        inventory_item_id que fica dentro de variants.
        """
        return self.mountRouteParams({
            "limit"  : "250",
            "fields" : "id,variants"
        })

    def afterGet_products_sync_inventory(self, data):
        """
        Extrai o array de produtos do envelope da Shopify.
        { "products": [...] } → [...]
        """
        return data.get('products', [])

    def pagination_rules(self, response, data):
        """
        Shopify REST usa cursor-based pagination via header Link.
        Formato: <https://loja.myshopify.com/admin/api/.../products.json?...&page_info=TOKEN>; rel="next"
        Extrai a URL completa do próximo cursor se existir.
        """
        link_header = response.headers.get('Link', '')

        # Procura pelo rel="next" no header Link
        for part in link_header.split(','):
            if 'rel="next"' in part:
                # Extrai apenas a URL entre < >
                match = re.search(r'<([^>]+)>', part)
                if match:
                    next_url = match.group(1)
                    # gn_api vai chamar pagination(rote=next_url)
                    # mas como a base_url já está no objeto de request
                    # precisamos retornar só o path + query string
                    # ex: products.json?limit=250&page_info=TOKEN
                    path = next_url.split('/admin/api/')[1]
                    # remove a versão da API do path para o gn_request concatenar corretamente
                    path = '/'.join(path.split('/')[1:])
                    return path

        return None  # sem próxima página, encerra paginação

    def sync_inventory_handler(self, data):
        """
        Itera sobre todos os produtos retornados pela Shopify e
        salva o inventory_item_id de cada variant em TBRELACIONAID
        com NMTABELA = 'TBPRODUTO_INVENTORY'.

        Usa self.relatement() que já faz INSERT ou UPDATE conforme
        o registro exista ou não — idempotente, pode rodar N vezes.
        """
        total    = len(data)
        salvos   = 0
        pulados  = 0

        self.createLog('001', f'Sync inventory_item_id iniciado — {total} produtos encontrados na Shopify')

        for produto in data:
            produto_id = produto.get('id')
            variants   = produto.get('variants', [])

            if not variants:
                self.createLog('400', f'Produto {produto_id} sem variants — pulado')
                pulados += 1
                continue

            for variant in variants: 
                variant_id = variant.get('id')
                inventory_item_id = variant.get('inventory_item_id')

                if not inventory_item_id:
                    self.createLog('400', f'Produto {produto_id} sem inventory_item_id — pulado')
                    pulados += 1
                    continue

                # Busca o CDPRODUTO (softdib_id) via TBRELACIONAID pelo product_id da Shopify
                softdib_id = self.translate(
                    id      = str(variant_id),
                    table   = 'TBPRODUTO',
                    reverse = True   # reverse=True: IDAPI → IDSOFTDIB
                )

                if not softdib_id:
                    self.createLog('400', f'Produto Shopify {produto_id} sem correspondência em TBRELACIONAID TBPRODUTO — pulado')
                    pulados += 1
                    continue

                # Grava/atualiza TBRELACIONAID para TBPRODUTO_INVENTORY
                self.relatement({
                    'NMNOMEAPI'         : self.apiName,
                    'NMTABELA'          : 'TBPRODUTO_INVENTORY',
                    'IDSOFTDIB'         : softdib_id,
                    'IDAPI'             : str(inventory_item_id),
                    'DTULTIMAALTERACAO' : 'now()',
                    'HASHMD5'           : 'INTEGRADO'
                })

                self.createLog('200', f'[{salvos+1}/{total}] {softdib_id} → inventory_item_id: {inventory_item_id}')
                salvos += 1

        self.createLog('001', f'Sync finalizado — salvos: {salvos} | pulados: {pulados}')

    def format_rote_products_sync_inventory(self, rote):
        """
        Substitui o nome da rota interna pelo endpoint real da Shopify.
        O gn_api monta 'products/sync_inventory?...' mas a API espera
        'products.json?...' — este método faz a correção antes da chamada.
        """
        # Pega apenas a query string que o rote_filter montou
        # ex: 'products/sync_inventory?limit=250&fields=id,variants'
        #                               ↓
        #     'products.json?limit=250&fields=id,variants'
        query_string = rote.split('?')[1] if '?' in rote else ''
        return f"products.json?{query_string}" if query_string else "products.json"

    
    # ================================================================ #
    # -------- ROTA: products/sync ------------------------- #
    # ================================================================ #

    def rote_filter_products_sync(self):
        """
        Parâmetros para buscar todos os produtos na Shopify.
        limit=250 é o máximo permitido pela REST API.
        fields=id,variants limita o payload — só precisamos do
        inventory_item_id que fica dentro de variants.
        """
        return self.mountRouteParams({
            "limit"  : "250",
            "fields" : "id,variants"
        })

    def afterGet_products_sync(self, data):
        """
        Extrai o array de produtos do envelope da Shopify.
        { "products": [...] } → [...]
        """
        return data.get('products', [])

    def pagination_rules(self, response, data):
        """
        Shopify REST usa cursor-based pagination via header Link.
        Formato: <https://loja.myshopify.com/admin/api/.../products.json?...&page_info=TOKEN>; rel="next"
        Extrai a URL completa do próximo cursor se existir.
        """
        link_header = response.headers.get('Link', '')

        # Procura pelo rel="next" no header Link
        for part in link_header.split(','):
            if 'rel="next"' in part:
                # Extrai apenas a URL entre < >
                match = re.search(r'<([^>]+)>', part)
                if match:
                    next_url = match.group(1)
                    # gn_api vai chamar pagination(rote=next_url)
                    # mas como a base_url já está no objeto de request
                    # precisamos retornar só o path + query string
                    # ex: products.json?limit=250&page_info=TOKEN
                    path = next_url.split('/admin/api/')[1]
                    # remove a versão da API do path para o gn_request concatenar corretamente
                    path = '/'.join(path.split('/')[1:])
                    return path

        return None  # sem próxima página, encerra paginação

    def sync_products_handler(self, data):
        """
        Itera sobre todos os produtos retornados pela Shopify e casa cada
        variant com o CDPRODUTO do Softdib pelo SKU, gravando duas relacoes
        na TBRELACIONAID:

            NMTABELA = 'TBPRODUTO'          -> variant_id
            NMTABELA = 'TBPRODUTO_PRODUCT'  -> product_id (rota variants/price)

        Usa self.relatement() que já faz INSERT ou UPDATE conforme
        o registro exista ou não — idempotente, pode rodar N vezes.
        """
        total    = len(data)
        salvos   = 0
        pulados  = 0

        self.createLog('001', f'Sync produtos iniciado — {total} produtos encontrados na Shopify')

        for produto in data:
            produto_id = produto.get('id')
            variants   = produto.get('variants', [])

            if not variants:
                self.createLog('400', f'Produto {produto_id} sem variants — pulado')
                pulados += 1
                continue

            for variant in variants:

                variant_sku = variant.get('sku')
                variant_id  = variant.get('id')

                if not variant_sku:
                    self.createLog('400', f'Produto {produto_id} variante {variant_id} sem SKU — pulado')
                    pulados += 1
                    continue

                # Busca o CDPRODUTO (softdib_id) via TBRELACIONAID pelo product_id da Shopify
                softdib_id = self.select(f"SELECT CDPRODUTO FROM TBPRODUTO WHERE CDPRODUTO = '{variant_sku}'")

                if not softdib_id:
                    self.createLog('400', f'Produto Shopify {produto_id} sem correspondência em TBRELACIONAID TBPRODUTO — pulado')
                    pulados += 1
                    continue

                # NMTABELA = 'TBPRODUTO' guarda o variant_id.
                #
                # HASHMD5 = 'INTEGRADO' e proposital: com NULL a rota products
                # entenderia o produto como novo (CASE do gn_api) e criaria um
                # duplicado na Shopify.
                self.relatement({
                    'NMNOMEAPI'         : self.apiName,
                    'NMTABELA'          : 'TBPRODUTO',
                    'IDSOFTDIB'         : variant_sku,
                    'IDAPI'             : str(variant_id),
                    'DTULTIMAALTERACAO' : 'now()',
                    'HASHMD5'           : "INTEGRADO"
                })

                # O product_id nao era guardado em lugar nenhum, e a mutation
                # de preco (productVariantsBulkUpdate) exige. Guardamos aqui,
                # de graca — o payload do sync ja traz os dois. Sem isso a
                # rota variants/price gasta uma consulta por produto para
                # descobrir a que produto o variant pertence.
                self.relatement({
                    'NMNOMEAPI'         : self.apiName,
                    'NMTABELA'          : 'TBPRODUTO_PRODUCT',
                    'IDSOFTDIB'         : variant_sku,
                    'IDAPI'             : str(produto_id),
                    'DTULTIMAALTERACAO' : 'now()',
                    'HASHMD5'           : None
                })

                self.createLog('200', f'[{salvos+1}/{total}] {softdib_id}')
                salvos += 1

        self.createLog('001', f'Sync finalizado — salvos: {salvos} | pulados: {pulados}')

    def format_rote_products_sync(self, rote):
        """
        Substitui o nome da rota interna pelo endpoint real da Shopify.
        O gn_api monta 'products/sync_inventory?...' mas a API espera
        'products.json?...' — este método faz a correção antes da chamada.
        """
        # Pega apenas a query string que o rote_filter montou
        # ex: 'products/sync_inventory?limit=250&fields=id,variants'
        #                               ↓
        #     'products.json?limit=250&fields=id,variants'
        query_string = rote.split('?')[1] if '?' in rote else ''
        return f"products.json?{query_string}" if query_string else "products.json"

    # ================================================================ #
    # --------- ROTA: orders/nota_fiscal ----------------------------- #
    # --------- Encaminha a NF-e como metafields do pedido ----------- #
    # ================================================================ #

    def beforeSend_orders_nota_fiscal(self, data):
        """
        Encaminha a NF-e como metafields do pedido na Shopify.

        Fluxo SEND: o gn_api chama este beforeSend antes do send() nativo.
        Como uma nota vira 3 metafields (numero, chave, url), disparamos
        os 3 aqui via self.throttling() e setamos ignore_request = True
        para cancelar o send() nativo — mesmo mecanismo usado na papapa.

        POST de metafield e UPSERT por namespace.key: reenviar atualiza,
        nao duplica.

        'data' e a linha ja mapeada (self.current_data tambem aponta p/ ela).
        """
        NAMESPACE = 'custom'

        pedido_ref = self.current_data.get('pedido_id')
        nf_numero  = self.current_data.get('nf_numero')
        nf_chave   = self.current_data.get('nf_chave')
        nf_url     = self.current_data.get('nf_url')

        # --- Resolve o ID do pedido na Shopify ---
        # Caso o campo do map ja traga o id externo, usa direto.
        # Se trouxer o nro interno, descomente a traducao via TBRELACIONAID.
        order_id = pedido_ref
        # order_id = self.translate(
        #     id      = str(pedido_ref),
        #     table   = 'TBPEDIDOSRETORNO',
        #     reverse = False               # IDSOFTDIB -> IDAPI (interno -> Shopify)
        # )

        # Sempre cancela o send nativo: quem envia somos nos, aqui.
        self.ignore_request = True

        if not order_id:
            self.createLog('400', f'NF {nf_numero}: pedido {pedido_ref} sem id Shopify — pulado')
            return data

        metafields = [
            ('nota_fiscal_numero', nf_numero, 'single_line_text_field'),
            ('nota_fiscal_chave',  nf_chave,  'single_line_text_field'),
            ('nota_fiscal_url',    nf_url,    'url'),
        ]

        for key, value, mf_type in metafields:
            if value in [None, '', 'None']:
                continue  # nao grava metafield vazio (evita 422)

            res = self.throttling(
                method = 'POST',
                rote   = f'orders/{order_id}/metafields.json',
                json   = {
                    'metafield': {
                        'namespace' : NAMESPACE,
                        'key'       : key,
                        'type'      : mf_type,
                        'value'     : str(value)
                    }
                }
            )

            if res.ok:
                self.createLog('200', f'NF {nf_numero}: metafield {key} gravado no pedido {order_id}')
            else:
                self.createLog(res.status_code, f'NF {nf_numero}: falha em {key} no pedido {order_id}: {res.text}')

        return data

    # ================================================================ #
    # --------- ROTA: orders/search ---------------------------------- #
    # ================================================================ #
 
    # A rota orders/search foi migrada do REST para o GraphQL. Motivo: o
    # CPF/CNPJ do checkout vive em order.localizationExtensions, campo que a
    # REST Admin API nao expoe. Buscando os pedidos por GraphQL o documento
    # vem no mesmo lote, em vez de 1 chamada extra por pedido.
    #
    # O tamanho do lote e pequeno de proposito: a Shopify cobra a query por
    # custo, com teto de 1000 pontos, e connections aninhadas multiplicam.
    #   orders(first:10) x lineItems(first:50) = ~510 pontos  -> passa
    #   orders(first:25) x lineItems(first:50) = ~1275 pontos -> recusada
    PEDIDOS_POR_PAGINA = 20
    ITENS_POR_PEDIDO   = 50

    def rote_filter_orders_search(self):
        """
        Monta o filtro de busca dos pedidos, na sintaxe de busca da Shopify
        (o argumento 'query' da connection orders):

          - status:any            → inclui pedidos abertos e fechados
          - financial_status:paid → apenas pedidos pagos
          - updated_at:>...       → desde a última importação (TBRELACIONAID)

        O updated_at é buscado da última data gravada em TBRELACIONAID
        para TBPEDIDOSRETORNO, igual ao padrão do lojainteg.py.

        Com filtro de pedidos na rota (orders/search?pedido=1007,1010) a busca
        vai direto pelo número do pedido (campo 'name' da Shopify, "#1007"),
        sem o corte por updated_at — senão pedido antigo nunca voltaria.
        """
        if self.filtro_pedidos:
            numeros = ' OR '.join(f"name:'#{pedido.lstrip('#')}'" for pedido in self.filtro_pedidos)
            return f"status:any financial_status:paid ({numeros})"

        sql = """
            SELECT DTULTIMAALTERACAO
            FROM   TBRELACIONAID
            WHERE  NMTABELA  = 'TBPEDIDOSRETORNO'
            AND    NMNOMEAPI = '{api}'
            ORDER  BY DTULTIMAALTERACAO DESC
            LIMIT  1
        """.format(api=self.apiName)
 
        fetch = self.getDb().select(sql)
 
        if fetch:
            alterado_apos = fetch[0]['DTULTIMAALTERACAO'].strftime('%Y-%m-%dT%H:%M:%S')
        else:
            # Primeira execução — busca pedidos dos últimos 60 dias
            alterado_apos = '1980-01-01T00:00:00'
        
        alterado_apos = '2026-10-09T00:00:00'

        # fulfillment_status:unfulfilled — só pedidos que ainda precisam de
        # trabalho. Exclui no servidor os já enviados (fulfilled/partial) e os
        # que já estão em separação (IN_PROGRESS não é token de busca, mas um
        # pedido IN_PROGRESS não é 'unfulfilled', então não vem). O skip por
        # fulfillment_status no _get_orders_graphql fica como rede de segurança.
        #return f"status:any financial_status:paid fulfillment_status:unfulfilled updated_at:>'{alterado_apos}'"
        return f"status:any fulfillment_status:unfulfilled updated_at:>'{alterado_apos}'"

    def format_rote_orders_search(self, rote):
        """
        O get_handler do gn_api monta uma rota REST antes de chamar o
        pagination(). Como esta rota vai por GraphQL, a URL montada aqui é
        descartada — devolvemos o endpoint real só pra manter o log coerente.
        """
        return 'graphql.json'

    # ================================================================ #
    # --------- ROTA: orders/search — COLETA VIA GRAPHQL ------------- #
    # ================================================================ #

    # Aliases devolvem os campos com os mesmos nomes do REST, pra que o
    # pedido_fields do map.json continue navegando por 'customer/email',
    # 'shipping_address/city' etc. O flatten do gn_graphql desfaz os
    # edges/node, e o _rest_shape resolve o que alias não alcança
    # (MoneyBag, gid, '#' do name).
    QUERY_PEDIDOS = """
    query($cursor: String, $limit: Int!, $itens: Int!, $filtro: String) {
      orders(first: $limit, after: $cursor, query: $filtro, sortKey: UPDATED_AT) {
        pageInfo { hasNextPage endCursor }
        edges { node {
          id:                   legacyResourceId
          admin_graphql_api_id: id
          order_number:         name
          created_at:           createdAt
          updated_at:           updatedAt
          email
          note
          source_name:          sourceName
          payment_gateway_names: paymentGatewayNames
          discount_codes:       discountCodes
          display_financial_status: displayFinancialStatus
          fulfillment_status:       displayFulfillmentStatus

          # O gateway do pedido nao distingue PIX de boleto quando o app de
          # pagamento atende os dois ("PagBank - PIX e Boleto" e um so nome
          # para as duas formas). Quem sabe a forma real e a transacao.
          #
          # paymentDetails e uma union: CardPaymentDetails,
          # LocalPaymentMethodsPaymentDetails, PaypalWalletPaymentDetails e
          # ShopPayInstallmentsPaymentDetails. PIX e boleto caem em
          # LocalPaymentMethods, e o nome da forma vem em paymentMethodName.
          transactions(first: 5) {
            kind
            status
            gateway
            formatted_gateway: formattedGateway
            payment_id:        paymentId
            processed_at:      processedAt
            amount_set:        amountSet { shop_money: shopMoney { amount } }
            payment_details:   paymentDetails {
              ... on LocalPaymentMethodsPaymentDetails {
                payment_method_name: paymentMethodName
                payment_descriptor:  paymentDescriptor
              }
              ... on CardPaymentDetails {
                payment_method_name: paymentMethodName
                company
                bin
                wallet
              }
            }
          }

          total_price:      totalPriceSet         { shop_money: shopMoney { amount } }
          total_discounts:  totalDiscountsSet     { shop_money: shopMoney { amount } }
          total_tax:        totalTaxSet           { shop_money: shopMoney { amount } }
          total_shipping_price_set: totalShippingPriceSet { shop_money: shopMoney { amount } }

          customer {
            id:         legacyResourceId
            first_name: firstName
            last_name:  lastName
            email
            phone
            metafields(first: 30) { edges { node { namespace key value } } }
          }

          shipping_address: shippingAddress {
            address1 address2 city zip phone company
            province_code: provinceCode
            country_code:  countryCodeV2
          }
          billing_address: billingAddress {
            address1 address2 city zip phone company
            province_code: provinceCode
            country_code:  countryCodeV2
          }

          # carrier_identifier e o que aponta para o carrier service que
          # cotou o frete: vem preenchido SO quando a cotacao foi feita por
          # um app (Intelipost, por exemplo). Quando a taxa e uma regra
          # manual da propria loja, ele vem null e source = "shopify".
          #
          # code e shipping_rate_handle sao onde o carrier service devolve a
          # referencia dele (na Intelipost, o delivery_method_id da opcao que
          # o comprador escolheu).
          shipping_lines: shippingLines(first: 10) { edges { node {
            id
            title
            code
            source
            custom
            phone
            carrier_identifier:   carrierIdentifier
            shipping_rate_handle: shippingRateHandle
            delivery_category:    deliveryCategory
            price:            originalPriceSet   { shop_money: shopMoney { amount } }
            discounted_price: discountedPriceSet { shop_money: shopMoney { amount } }
          } } }

          line_items: lineItems(first: $itens) { edges { node {
            sku
            title
            quantity
            variant  { variant_id: legacyResourceId }
            product  { product_id: legacyResourceId }
            price:          originalUnitPriceSet   { shop_money: shopMoney { amount } }
            total_discount: totalDiscountSet       { shop_money: shopMoney { amount } }
          } } }

          customAttributes { key value }
          localizationExtensions(first: 10) { edges { node { key value purpose } } }
          metafields(first: 30) { edges { node { namespace key value } } }
        } }
      }
    }
    """

    def pagination(self, rote):
        """
        Intercepta apenas a rota de pedidos, que vai por GraphQL. Qualquer
        outra rota GET (products/sync_inventory, por exemplo) segue no fluxo
        REST original do gn_api.
        """
        if self.fixed_rote() == 'orders_search':
            return self._get_orders_graphql()

        return super().pagination(rote)

    def _get_orders_graphql(self):
        """
        Percorre os pedidos via GraphQL e devolve a lista já no formato REST,
        que é o que o pedidos_handler / extractValues esperam receber.
        """
        filtro = self.rote_filter_orders_search()

        self.createLog('444', f'graphql orders — filtro: {filtro}')

        pedidos  = []
        ignorados = 0

        # Status de fulfillment (nível do pedido) que NÃO devem ser importados:
        # o pedido já está em separação ou já foi enviado, então reimportar
        # só reabriria algo que o ERP já tratou.
        if self.filtro_pedidos:
            IGNORAR_FULFILLMENT = ()
        else:
            IGNORAR_FULFILLMENT = ('IN_PROGRESS',)
            
        for node in self.shopify_graphql.paginate(
            self.QUERY_PEDIDOS,
            path      = 'orders',
            variables = {'filtro': filtro, 'itens': self.ITENS_POR_PEDIDO},
            page_size = self.PEDIDOS_POR_PAGINA
        ):
            fulfillment = (node.get('fulfillment_status') or '').upper()

            if fulfillment in IGNORAR_FULFILLMENT:
                ignorados += 1
                self.createLog('204', f"Pedido {node.get('order_number')} ignorado na "
                                      f"importação — fulfillment_status={fulfillment} "
                                      f"(já em separação).")
                continue

            pedidos.append(self._rest_shape(node))

        self.createLog('001', f'graphql orders — {len(pedidos)} pedidos recebidos, '
                              f'{ignorados} ignorados por fulfillment_status.')

        return pedidos

    def _rest_shape(self, no):
        """
        Ajusta o que os aliases da query não conseguem resolver sozinhos:

        1. MoneyBag  — o GraphQL só tem { shop_money: { amount } }, enquanto o
           REST tem 'total_price' escalar e 'total_price_set' aninhado.
           A regra segue essa mesma convenção: achata quando a chave NÃO
           termina em '_set'.
        2. order_number — o GraphQL devolve o 'name' ("#1007"); o REST devolve
           1007 inteiro.
        3. ids — legacyResourceId vem string; no REST são inteiros.
        4. variant/product — o GraphQL aninha { product: { product_id } };
           o REST tem 'product_id' direto no item.
        5. discount_codes — no GraphQL é lista de strings; no REST é lista de
           objetos com a chave 'code'.
        """
        def eh_moneybag(valor):
            return (isinstance(valor, dict)
                    and isinstance(valor.get('shop_money'), dict)
                    and 'amount' in valor['shop_money'])

        def anda(valor, chave=None):
            if isinstance(valor, list):
                return [anda(item) for item in valor]

            if isinstance(valor, dict):
                if eh_moneybag(valor) and not str(chave or '').endswith('_set'):
                    return valor['shop_money']['amount']
                return {k: anda(v, k) for k, v in valor.items()}

            return valor

        no = anda(no)

        # 2. '#1007' -> 1007
        numero = no.get('order_number')
        if isinstance(numero, str):
            numero = numero.lstrip('#')
            no['order_number'] = int(numero) if numero.isdigit() else numero

        # 3. ids do REST são inteiros
        no['id'] = self._para_int(no.get('id'))
        if isinstance(no.get('customer'), dict):
            no['customer']['id'] = self._para_int(no['customer'].get('id'))

        # 4. achata variant/product dentro dos itens
        for item in no.get('line_items') or []:
            for aninhado in ('variant', 'product'):
                valores = item.pop(aninhado, None)
                if isinstance(valores, dict):
                    for chave, valor in valores.items():
                        item[chave] = self._para_int(valor)

        # 5. ['CUPOM10'] -> [{'code': 'CUPOM10'}]
        codigos = no.get('discount_codes')
        if isinstance(codigos, list) and all(isinstance(c, str) for c in codigos):
            no['discount_codes'] = [{'code': c} for c in codigos]

        return no

    def _para_int(self, valor):
        """legacyResourceId chega como string; o REST devolve inteiro."""
        try:
            return int(valor)
        except (TypeError, ValueError):
            return valor
 
    def get_order_data(self, order):
        # """
        # Mapeia os campos do pedido Shopify para a estrutura esperada
        # pela API Softdib (/integracao/pedido/pedido).
 
        # Retorna uma lista com um dict (padrão do pedidos_handler).
        # """
        # cliente      = order.get('customer') or {}
        # shipping     = order.get('shipping_address') or {}
        # billing      = order.get('billing_address') or {}
        # pagamentos   = order.get('payment_gateway_names', [])
        shipping_l   = order.get('shipping_lines', [])
        # disc_codes   = order.get('discount_codes', [])
 
        # # Endereço de entrega — prefere shipping_address, fallback billing
        # end_entrega  = shipping if shipping else billing
 
        # # Forma de pagamento
        # gateway      = pagamentos[0] if pagamentos else ''
 
        # # Código de condição de pagamento
        # cdcondpgto   = self.pagamentos_shopify(order)
 
        # # Transportadora
        cdtransportador = self.transportadora_shopify(shipping_l)
 
        # # Dados do cliente
        # nome_completo = f"{cliente.get('first_name', '')} {cliente.get('last_name', '')}".strip()
        # telefone_raw  = cliente.get('phone') or end_entrega.get('phone') or ''
        cpf_cnpj      = self._get_cpf_cnpj(order)
 
        # # Traduz customer.id da Shopify para CDCLIENTE do Softdib
        # cdcliente = self.translate(
        #     id      = str(cliente.get('id', '')),
        #     table   = 'TBCLIENTE',
        #     reverse = True
        # ) if cliente.get('id') else ''
 
        # data = {
        #     # Identificadores do pedido
        #     'IDTBPEDIDO'            : str(order.get('id', '')),
        #     'CDSEQPEDIDOEXTERNO'    : str(order.get('id', '')),
 
        #     # Datas
        #     'DTPEDIDO'              : (order.get('created_at') or '')[:19].replace('T', ' '),
        #     'DTULTIMAALTERACAO'     : (order.get('updated_at') or '')[:19].replace('T', ' '),
 
        #     # Cliente
        #     'CDCLIENTE'             : cdcliente,
        #     'IDCLIENTEEXTERNO'      : str(cliente.get('id', '')),
        #     'RAZAO_SOCIAL'          : nome_completo,
        #     'NOME_FANTASIA'         : nome_completo,
        #     'EMAIL'                 : cliente.get('email') or order.get('email') or '',
        #     'CNPJ_CPF'              : re.sub(r'[^\d]', '', cpf_cnpj),
        #     'TIPO_PESSOA'           : 'J' if len(re.sub(r'[^\d]', '', cpf_cnpj)) == 14 else 'F',
        #     'SEXO'                  : '',
        #     'RG_IE'                 : '',
 
        #     # Telefone — extrai DDD e número
        #     'DDD'                   : self.translateByType(telefone_raw, 'ddd')      if telefone_raw else '',
        #     'TELEFONE'              : self.translateByType(telefone_raw, 'numero')   if telefone_raw else '',
 
        #     # Endereço de entrega
        #     'ENDERECO_ENT'          : end_entrega.get('address1') or '',
        #     'ENDERECO_NRO_ENT'      : end_entrega.get('address2') or 'SN',
        #     'COMPLEMENTO_ENT'       : '',
        #     'REFERENCIA_ENT'        : '',
        #     'CEP_ENT'               : re.sub(r'[^\d]', '', end_entrega.get('zip') or ''),
        #     'BAIRRO_ENT'            : end_entrega.get('address2') or '',
        #     'CIDADE_ENT'            : end_entrega.get('city') or '',
        #     'ESTADO_ENT'            : end_entrega.get('province_code') or '',
 
        #     # Logística e pagamento
        #     'CDTRANSPORTADOR'       : cdtransportador,
        #     'CDFRETE'               : '1',
        #     'VLFRETE'               : str(order.get('total_shipping_price_set', {}).get('shop_money', {}).get('amount', '0') or '0'),
        #     'CDCONDPGTO'            : cdcondpgto,
        #     'CDBANDEIRA'            : gateway,
        #     'VLQTDEPARCELAS'        : '1',
        #     'VLPARCELA'             : str(order.get('total_price', '0')),
        #     'TID'                   : '',
 
        #     # Valores
        #     'VLTOTAL'               : str(order.get('total_price', '0')),
        #     'VLDESCONTO'            : str(order.get('total_discounts', '0')),
 
        #     # Obs e cupom
        #     'DSOBSPEDIDO'           : order.get('note') or '',
        #     'CUPOMDESCONTO'         : disc_codes[0].get('code', '') if disc_codes else '',
 
        #     # Previsão de entrega — calculada nos itens
        #     'DTPREVENTREGA'         : '2',
 
        #     # Defaults Softdib
        #     'CDVENDEDOR'            : 'V00006',
        #     'CDUSUARIO'             : 'SAC',
        #     'CDORIGEM'              : self._get_origem_shopify(order),
 
        #     # Empresa/filial (adicionados pelo extractValues no gn_api)
        #     'CDEMPRESA'             : self.cdempresa,
        #     'FILIAL'                : self.cdfilial,
        # }
 
        # # Normaliza número do endereço — se não for dígito, move para complemento
        # if data['ENDERECO_NRO_ENT'] and not str(data['ENDERECO_NRO_ENT']).isdigit():
        #     nro_raw               = data['ENDERECO_NRO_ENT']
        #     data['ENDERECO_NRO_ENT'] = ''.join(re.findall(r'\d+', nro_raw)) or 'SN'
        #     data['COMPLEMENTO_ENT']  = f"{nro_raw} - {data['COMPLEMENTO_ENT']}".strip(' -')

        order['documento_cliente'] = cpf_cnpj  # adiciona o documento no pedido para ser usado no extractValues
        order['cdtransportador'] = cdtransportador  # adiciona o cdtransportador no pedido para ser usado no extractValues

        return [ self.extractValues(order, 'pedido_fields') ]
 
    def get_order_items(self, order):
        return [ self.extractValues(item, "itenscarrinho_fields") for item in order['line_items']]
 
    def _cdproduto_do_item(self, item) -> str:
        """
        Resolve o CDPRODUTO do item do pedido.

        O SKU é o caminho preferido — na nossa integração o SKU já é o
        CDPRODUTO. Mas há variante cadastrada na Shopify sem SKU (o campo vem
        presente e nulo no payload), e aí o item ficava sem produto.

        Fallback: traduz o product_id via TBRELACIONAID, relação que a rota
        products já grava em afterSend_products / sync_inventory_handler.
        """
        sku = str(item.get('sku') or '').strip()

        if sku:
            return sku.upper()

        product_id = item.get('product_id')

        cdproduto = self.translate(
            id      = str(product_id),
            table   = 'TBPRODUTO',
            reverse = True
        ) if product_id else ''

        if not cdproduto:
            self.createLog(
                '400',
                f'Item sem SKU e sem relação em TBRELACIONAID: '
                f'product_id={product_id} | {item.get("title")}'
            )
            return ''

        return str(cdproduto).upper()

    def carrinho_quantidade(self, value) :
        value = 0
        if re.match(r'.*-(C([2-9]|1[0]?)(P([2-9]|1[0]?))?)$', self.codigo_produto.upper()):
            value = 100
            
        return str(value)
    


    def customer_data(self, data):
        """
        Enriquece os dados do cliente com informações da Shopify.
        Chamado pelo pedidos_handler quando o cliente não existe no Softdib
        ou quando alwaysUpdateClient = true.
 
        Busca o cliente pelo ID da Shopify para obter dados completos
        (endereço principal, RG/IE, etc.).
        """
        cliente_id = self.current_order.get('customer', {}).get('id')
 
        if not cliente_id:
            return data
 
        res = self.throttling(method='GET', rote=f'customers/{cliente_id}.json')
 
        if not res.ok:
            self.createLog(res.status_code, f'Falha ao buscar cliente {cliente_id}: {res.text}')
            return data
 
        cliente_shopify = res.json().get('customer', {})
 
        # Endereços — pega o endereço padrão
        enderecos = cliente_shopify.get('addresses', [])
        end_principal = next((e for e in enderecos if e.get('default')), enderecos[0] if enderecos else {})
 
        # Atualiza dados com informações mais completas do cadastro do cliente
        if end_principal:
            data['ENDERECO']    = end_principal.get('address1') or data.get('ENDERECO_ENT', '')
            data['ENDERECO_NRO']= end_principal.get('address2') or 'SN'
            data['COMPLEMENTO'] = ''
            data['CEP']         = re.sub(r'[^\d]', '', end_principal.get('zip') or '')
            data['BAIRRO']      = end_principal.get('address2') or ''
            data['CIDADE']      = end_principal.get('city') or ''
            data['ESTADO']      = end_principal.get('province_code') or ''

            # data["CEP_ENT"           ] = self.only_numbers(dados_cliente['shipping'].get("postcode",''))

            # Normaliza número do endereço
            if data['ENDERECO_NRO'] and not str(data['ENDERECO_NRO']).isdigit():
                nro_raw             = data['ENDERECO_NRO']
                data['ENDERECO_NRO']= ''.join(re.findall(r'\d+', nro_raw)) or 'SN'
                data['COMPLEMENTO'] = f"{nro_raw} - {data['COMPLEMENTO']}".strip(' -')
 
        # ID externo do cliente na Shopify
        data['IDCLIENTEEXTERNO'] = str(cliente_id)
 
        return data
 
 
    # ================================================================ #
    # --------- CPF / CNPJ DO CLIENTE (GraphQL) ---------------------- #
    # ================================================================ #

    QUERY_DOCUMENTO = """
    query($id: ID!) {
      order(id: $id) {
        id
        name
        customAttributes { key value }
        localizationExtensions(first: 10) {
          edges { node { key value purpose title } }
        }
        metafields(first: 30) {
          edges { node { namespace key value } }
        }
        customer {
          id
          metafields(first: 30) {
            edges { node { namespace key value } }
          }
        }
      }
    }
    """

    # Chaves onde apps de checkout costumam gravar o documento
    CHAVES_DOCUMENTO = ('cpf', 'cnpj', 'documento', 'tax_credential', 'tax_id', 'doc')

    def _get_cpf_cnpj(self, order) -> str:
        """
        Recupera o CPF/CNPJ do pedido.

        A REST Admin API não devolve o documento em lugar nenhum. Na Shopify
        Brasil ele é gravado como localizationExtension do pedido, com a
        chave TAX_CREDENTIAL_BR — campo exposto apenas no GraphQL.

        Como a rota orders/search já traz localizationExtensions no lote, na
        importação normal o documento é extraído sem nenhuma chamada extra.
        A consulta pontual só dispara quando o pedido chegou por outro
        caminho (REST, reprocessamento manual) e não trouxe os campos.

        Retorna apenas os dígitos, ou '' se não encontrar.
        """
        documento = self._documento_do_pedido(order, order)

        if documento:
            return documento

        # Pedido sem os campos do GraphQL: consulta pontual
        gid  = order.get('admin_graphql_api_id') or f"gid://shopify/Order/{order.get('id')}"
        data = self.shopify_graphql.run(self.QUERY_DOCUMENTO, {'id': gid})

        pedido = data.get('order') or {}

        if not pedido:
            self.createLog('400', f'GraphQL não retornou o pedido {gid} — verifique os escopos do app')
            return ''

        documento = self._documento_do_pedido(pedido, order)

        if not documento:
            self.createLog(
                '999',
                f'CPF/CNPJ não encontrado no pedido {order.get("order_number")} ({gid})'
            )

        return documento

    def _documento_do_pedido(self, fonte, order=None) -> str:
        """
        Procura o documento em cascata e devolve o primeiro valor com
        11 (CPF) ou 14 (CNPJ) dígitos:

          1. customAttributes  (note_attributes do REST)
          2. localizationExtensions → TAX_CREDENTIAL_BR   ← caminho oficial
          3. metafields do pedido
          4. metafields do cliente
          5. billing/shipping address.company             ← gambiarra comum

        'fonte'  é o pedido de onde ler os campos GraphQL.
        'order'  é o pedido original, usado só para o fallback de company.
        """
        candidatos = []

        # 1. customAttributes (note_attributes no REST)
        for attr in (fonte.get('customAttributes') or fonte.get('note_attributes') or []):
            if self._chave_de_documento(attr.get('key') if isinstance(attr, dict) else None):
                candidatos.append(attr.get('value'))

        # 2. localizationExtensions — TAX_CREDENTIAL_BR
        # (o gn_graphql já achatou edges/node, então isto é uma lista)
        for ext in fonte.get('localizationExtensions') or []:
            if 'CREDENTIAL' in str(ext.get('key') or '').upper():
                candidatos.append(ext.get('value'))

        # 3 e 4. metafields do pedido e do cliente
        cliente = fonte.get('customer') or {}
        for origem in (fonte.get('metafields'), cliente.get('metafields')):
            for mf in origem or []:
                if self._chave_de_documento(mf.get('key')):
                    candidatos.append(mf.get('value'))

        # 5. Fallback: alguns apps gravam o documento em company
        for pedido in (fonte, order):
            if not isinstance(pedido, dict):
                continue
            candidatos.append((pedido.get('billing_address')  or {}).get('company'))
            candidatos.append((pedido.get('shipping_address') or {}).get('company'))

        for valor in candidatos:
            digitos = re.sub(r'[^\d]', '', str(valor or ''))
            if len(digitos) in (11, 14):
                return digitos

        return ''

    def _chave_de_documento(self, chave) -> bool:
        chave = str(chave or '').lower()
        return any(c in chave for c in self.CHAVES_DOCUMENTO)

    def _tipo_pessoa(self, value) :
            return "J" if len(value) > 11 else "F"

    def _id_cotacao(self, value) :
        #0 ={'key': 'shipping_additional_delivery_date', 'value': '29/10/2026'}
        #1 ={'key': 'intelipost_location_1_81200-200', 'value': '979210393490216'}
        #2 ={'key': 'intelipost_delivery_method_id_1_81200-200', 'value': '1'}
        #3 ={'key': 'intelipost_delivery_method_name_1_81200-200', 'value': 'Correios PAC'}
        #4 ={'key': 'intelipost_delivery_method_type_1_81200-200', 'value': 'STANDARD'}
        if len(value) == 0:
            return "0"
        
        return value[1]['value'] if len(value[1]['value']) > 0 else "0"

    def _id_metodo_envio(self, value) :
        if len(value) == 0:
            return "0"
        
        return value[2]['value'] if len(value[2]['value']) > 0 else "0"


    # Variações de "sem número": s/n, s/nº, sn, s n
    RE_SEM_NUMERO = re.compile(r'\bs\s*/?\s*n[\u00ba\u00b0o]?\b', re.IGNORECASE)

    def _capturar_numero_endereco(self, value) -> str:
        """
        Extrai o número do logradouro a partir do address1 da Shopify.

        A Shopify não tem campo separado para número — ele vem grudado no
        address1, no formato brasileiro. Exemplos reais e esperados:

            'Rua Mário Campos, 51'          -> '51'
            'Av. Paulista, 1000 - Apto 52'  -> '1000'
            'Rua A, 45, Apto 3'             -> '45'
            'Rua A, Jardim B, 45'           -> '45'
            'Rua das Flores 123'            -> '123'
            'Rua 25 de Março, 500'          -> '500'
            'Rua 25 de Março'               -> 'SN'
            'Rua X, s/n'                    -> 'SN'

        Regras, nesta ordem:

          1. Qualquer variação de 's/n' → 'SN'.

          2. Havendo vírgula, o número é o primeiro grupo de dígitos do
             primeiro trecho APÓS a primeira vírgula que contenha dígito.
             O trecho inicial é o logradouro (por isso é pulado), e o que
             vem depois do número costuma ser complemento — daí pegar o
             primeiro grupo, não o último.

          3. Sem vírgula, só aceita dígitos no FIM da string. É o que evita
             'Rua 25 de Março' virar 25.

          4. Não achou → 'SN', com log para mapeamento posterior.
        """
        endereco = str(value or '').strip()

        if not endereco:
            return 'SN'

        # 1. sem número declarado
        if self.RE_SEM_NUMERO.search(endereco):
            return 'SN'

        # 2. trechos após a primeira vírgula
        trechos = endereco.split(',')

        for trecho in trechos[1:]:
            achado = re.search(r'\d[\d.]*', trecho)

            if not achado:
                continue

            digitos = re.sub(r'[^\d]', '', achado.group())

            if digitos:
                return digitos

        # 3. dígitos no fim da string (aceita sufixo de uma letra: '45-A')
        achado = re.search(r'(\d[\d.]*)\s*[-/]?\s*[A-Za-z]?$', endereco)

        if achado:
            digitos = re.sub(r'[^\d]', '', achado.group(1))

            if digitos:
                return digitos

        # 4. não identificado
        self.createLog('999', f'Número não identificado no endereço: {endereco!r}')
        return 'SN'


    # ================================================================ #
    # --------- HELPERS — PEDIDOS ------------------------------------ #
    # ================================================================ #
 
    # ================================================================ #
    # --------- FORMA DE PAGAMENTO — PIX x BOLETO x CARTAO ----------- #
    # ================================================================ #

    # Como os apps de pagamento nomeiam a forma. A chave e comparada por
    # "contem", sem acento e em maiusculo, contra paymentMethodName,
    # formattedGateway e gateway — nessa ordem de confianca.
    FORMAS_PAGAMENTO = [
        ('PIX',     ['PIX']),
        ('BOLETO',  ['BOLETO', 'BOLETO BANCARIO', 'BANK_SLIP', 'BANKSLIP']),
        ('CARTAO',  ['CREDIT', 'CARD', 'CARTAO', 'VISA', 'MASTERCARD', 'ELO', 'AMEX', 'HIPERCARD']),
        ('DEBITO',  ['DEBIT']),
    ]

    QUERY_TRANSACOES_RECIBO = """
        query reciboTransacoes($id: ID!) {
            order(id: $id) {
                transactions(first: 5) {
                    kind
                    status
                    gateway
                    formattedGateway
                    receiptJson
                }
            }
        }
    """

    def forma_pagamento_shopify(self, order) -> str:
        """
        Devolve 'PIX', 'BOLETO', 'CARTAO', 'DEBITO' ou '' (indefinido).

        O pedido em si nao sabe a forma: quando o app de pagamento atende
        PIX e boleto, payment_gateway_names traz o mesmo nome nos dois casos
        ("PagBank - PIX e Boleto"). Quem sabe e a transacao.

        Ordem de confianca:
          1. transactions[].payment_details.payment_method_name — o app diz
             explicitamente qual forma o comprador usou
          2. transactions[].formatted_gateway — nome legivel, as vezes ja vem
             com a forma ("PagBank - PIX")
          3. transactions[].gateway / payment_gateway_names — ultimo recurso,
             so acerta quando o gateway atende uma forma so

        Quando nada casa, loga o payload cru das transacoes para que a forma
        possa ser mapeada em FORMAS_PAGAMENTO.
        """
        transacoes = [
            t for t in (order.get('transactions') or [])
            if (t.get('kind') or '').upper() in ['SALE', 'CAPTURE', 'AUTHORIZATION', '']
            and (t.get('status') or '').upper() in ['SUCCESS', 'PENDING', '']
        ] or (order.get('transactions') or [])

        candidatos = []

        for transacao in transacoes:
            detalhes = transacao.get('payment_details') or {}
            candidatos.append(detalhes.get('payment_method_name'))

        for transacao in transacoes:
            candidatos.append(transacao.get('formatted_gateway'))
            candidatos.append(transacao.get('gateway'))

        candidatos.extend(order.get('payment_gateway_names') or [])

        for candidato in candidatos:
            forma = self._casar_forma_pagamento(candidato)
            if forma:
                return forma

        self._log_pagamento_indefinido(order)
        return ''

    def _casar_forma_pagamento(self, texto) -> str:
        """
        Casa um texto com uma forma de pagamento, ou devolve '' quando nao da
        para decidir.

        Texto que casa com MAIS DE UMA forma e descartado de proposito: o nome
        do gateway "PagBank - PIX e Boleto" contem as duas palavras, e aceitar
        a primeira que aparece devolveria PIX para todo pedido de boleto —
        que e justamente o bug que esta rotina existe para evitar.
        """
        if not texto:
            return ''

        # Sem acento, maiusculo — 'Boleto Bancário' e 'BOLETO BANCARIO' casam
        normalizado = unicodedata.normalize('NFKD', str(texto))
        normalizado = normalizado.encode('ASCII', 'ignore').decode().upper()

        casadas = [
            forma
            for forma, apelidos in self.FORMAS_PAGAMENTO
            if any(apelido in normalizado for apelido in apelidos)
        ]

        return casadas[0] if len(casadas) == 1 else ''

    def _log_pagamento_indefinido(self, order):
        """
        Despeja o que veio da Shopify sobre o pagamento do pedido. E por aqui
        que se descobre como o app nomeia a forma, para incluir o apelido em
        FORMAS_PAGAMENTO.
        """
        pedido = order.get('order_number') or order.get('id')

        self.createLog(
            '999',
            f'Pedido {pedido}: forma de pagamento indefinida | '
            f'gateways={json.dumps(order.get("payment_gateway_names") or [], ensure_ascii=False)} | '
            f'transactions={json.dumps(order.get("transactions") or [], ensure_ascii=False)[:2000]}'
        )

        if not self.debug:
            return

        # Em debug vai um passo alem: o receiptJson e o payload cru que o app
        # de pagamento anexou a transacao. Nao tem contrato estavel, entao fica
        # fora da query principal — se a loja nao tiver acesso ao campo, a
        # consulta falha aqui, isolada, sem derrubar a importacao dos pedidos.
        gid = order.get('admin_graphql_api_id')

        if not gid:
            return

        recibo = self.shopify_graphql.run(self.QUERY_TRANSACOES_RECIBO, {'id': gid})
        transacoes = ((recibo or {}).get('order') or {}).get('transactions') or []

        self.createLog(
            '999',
            f'Pedido {pedido}: receiptJson={json.dumps(transacoes, ensure_ascii=False)[:4000]}'
        )

    def pagamentos_shopify(self, order) -> str:
        """
        Traduz o gateway de pagamento da Shopify para CDCONDPGTO do Softdib.
 
        Mapeia a forma de pagamento + número de parcelas para a descrição
        da condição de pagamento gravada em TBCONDPGTO.DSCONDPGTO.
 
        Adicione mapeamentos conforme os gateways ativos na sua loja.
        """
        gateways     = order.get('payment_gateway_names', [])
        gateway      = gateways[0] if gateways else ''
        parcelas     = '1'  # Shopify REST não expõe parcelas diretamente
 
        dicionario = {
            'shopify_payments'              : 'Cartao Shopify Payments',
            'paypal'                        : 'Paypal',
            'mercadopago'                   : 'Mercado Pago',
            'pagseguro'                     : 'PAGSEGURO',
            'paghiper'                      : 'BOLETO PAGHIPER',
            'bogus'                         : 'Cartao Lj Virtual',  # gateway de teste
            'manual'                        : 'MANUAL',
            'gift_card'                     : 'VALE PRESENTE',
            'cash'                          : 'DINHEIRO',
        }
 
        forma = dicionario.get(gateway, gateway)
 
        # Boleto/PIX/Gift não tem parcela
        if any(kw in forma.upper() for kw in ['BOLETO', 'PIX', 'MANUAL', 'DINHEIRO', 'VALE']):
            desc_pagamento = forma
        else:
            desc_pagamento = f"{forma} {parcelas}X"
 
        result = self.getDb().simple_select(
            fields    = ['CDCONDPGTO'],
            table     = 'TBCONDPGTO',
            where     = [f"DSCONDPGTO='{desc_pagamento}'"],
            onlyFirst = True
        )
 
        return result.get('CDCONDPGTO', '0') if result else '0'
 
    # ================================================================ #
    # --------- REFERENCIA DA COTACAO DE FRETE (Intelipost) ---------- #
    # ================================================================ #

    # Chaves de customAttributes / metafields onde apps de frete costumam
    # deixar a cotacao. Comparadas em minusculo, por "contem".
    CHAVES_COTACAO_FRETE = ['quote_id', 'quoteid', 'cotacao', 'delivery_method_id',
                            'deliverymethod', 'intelipost']

    QUERY_ENTREGA_PEDIDO = """
        query entregaPedido($id: ID!) {
            order(id: $id) {
                fulfillmentOrders(first: 3) {
                    edges { node {
                        deliveryMethod {
                            methodType
                            serviceCode
                            sourceReference
                            presentedName
                        }
                    } }
                }
            }
        }
    """

    def referencia_frete_shopify(self, order) -> dict:
        """
        Junta tudo que o pedido sabe sobre a opcao de frete escolhida.

        Devolve um dict com o que foi encontrado:
            titulo, codigo, carrier, handle, origem, categoria, cotacao

        'cotacao' e o que interessa para dar sequencia na Intelipost — o
        delivery_method_id / quote_id da opcao escolhida pelo comprador.

        Importante: esses campos so vem preenchidos quando a cotacao foi
        feita por um carrier service (o app da Intelipost cotando no
        checkout). Se o frete for uma regra manual da loja, a Shopify
        devolve carrier_identifier = null e source = "shopify", e nao existe
        ID nenhum para recuperar — nao e a consulta que esta incompleta, e a
        cotacao que nao passou pela Intelipost.
        """
        linhas = order.get('shipping_lines') or []
        linha  = linhas[0] if linhas else {}

        dados = {
            'titulo'    : linha.get('title'),
            'codigo'    : linha.get('code'),
            'carrier'   : linha.get('carrier_identifier'),
            'handle'    : linha.get('shipping_rate_handle'),
            'origem'    : linha.get('source'),
            'categoria' : linha.get('delivery_category'),
            'cotacao'   : None,
        }

        # A referencia da cotacao, na ordem em que costuma aparecer:
        #   1. atributos do carrinho / metafields gravados pelo app
        #   2. code da shipping_line — onde o carrier service devolve o id
        #      da opcao que cotou
        #   3. shipping_rate_handle — formato instavel, mas costuma embutir
        #      o code quando o resto vem vazio
        dados['cotacao'] = (
            self._cotacao_nos_atributos(order)
            or (linha.get('code') if linha.get('carrier_identifier') else None)
            or (linha.get('shipping_rate_handle') if linha.get('carrier_identifier') else None)
        )

        if not dados['cotacao']:
            self._log_frete_sem_cotacao(order, dados)

        return dados

    def _cotacao_nos_atributos(self, order):
        """
        Procura a cotacao nos customAttributes (note_attributes no REST) e
        nos metafields do pedido. E onde a maioria dos apps de frete grava,
        porque sobrevive ao checkout sem depender do carrier service.
        """
        candidatos = []

        for atributo in order.get('customAttributes') or []:
            candidatos.append((atributo.get('key'), atributo.get('value')))

        for metafield in order.get('metafields') or []:
            chave = f"{metafield.get('namespace') or ''}.{metafield.get('key') or ''}"
            candidatos.append((chave, metafield.get('value')))

        for chave, valor in candidatos:
            if not chave or not valor:
                continue

            if any(alvo in str(chave).lower() for alvo in self.CHAVES_COTACAO_FRETE):
                return str(valor)

        return None

    def _log_frete_sem_cotacao(self, order, dados):
        """
        Sem referencia de cotacao: despeja o que veio, para dar para ver se o
        problema e a consulta ou e a configuracao do checkout.
        """
        pedido = order.get('order_number') or order.get('id')

        if (dados.get('origem') or '').lower() == 'shopify' and not dados.get('carrier'):
            self.createLog(
                '999',
                f'Pedido {pedido}: frete cotado pela propria Shopify '
                f'(source=shopify, carrier_identifier=null) — a opcao "{dados.get("titulo")}" '
                f'nao passou por carrier service, entao nao existe id de cotacao a recuperar'
            )
            return

        self.createLog(
            '999',
            f'Pedido {pedido}: sem referencia de cotacao | '
            f'shipping_lines={json.dumps(order.get("shipping_lines") or [], ensure_ascii=False)[:1500]} | '
            f'customAttributes={json.dumps(order.get("customAttributes") or [], ensure_ascii=False)[:500]}'
        )

        if not self.debug:
            return

        # fulfillmentOrders exige escopo proprio (read_*_fulfillment_orders).
        # Fica fora da query principal: se o app nao tiver o escopo, a falha
        # acontece aqui, isolada, sem derrubar a importacao dos pedidos.
        gid = order.get('admin_graphql_api_id')

        if not gid:
            return

        entrega = self.shopify_graphql.run(self.QUERY_ENTREGA_PEDIDO, {'id': gid})
        ordens  = ((entrega or {}).get('order') or {}).get('fulfillmentOrders') or []

        self.createLog(
            '999',
            f'Pedido {pedido}: deliveryMethod={json.dumps(ordens, ensure_ascii=False)[:2000]}'
        )

    def transportadora_shopify(self, shipping_lines: list) -> str:
        """
        Traduz a shipping_line da Shopify para CDTRANSPORTADOR do Softdib.
 
        shipping_lines[0].title → descrição da forma de envio.
        Adicione mapeamentos conforme as transportadoras configuradas na loja.
        """
        if not shipping_lines:
            return 'T00001'  # transportadora padrão
 
        titulo = shipping_lines[0].get('title', '').lower()
        codigo = shipping_lines[0].get('code', '').lower()
 
        transportadoras = {
            'sedex'         : 'T00008',
            'pac'           : 'T00008',
            'retirar'       : 'T00226',
            'local pickup'  : 'T00226',
            'free'          : 'T00226',
            'j&t'           : 'T00172',
            'loggi'         : 'T00072',
            'dominio'       : 'T00145',
            'translovato'   : 'T00222',
            'jamef'         : 'T00340',
            'standard'      : 'T00226' 
            
        }
 
        for chave, valor in transportadoras.items():
            if chave in titulo or chave in codigo:
                return valor
 
        # Não encontrou — loga para mapeamento e retorna padrão
        self.createLog('999', f'Transportadora não mapeada: {titulo} / {codigo}')
        return 'T00001'
 
    def _get_origem_shopify(self, order) -> str:
        """
        Define CDORIGEM com base na origem do pedido.
        Shopify não tem marketplace por padrão — retorna código da loja virtual.
        Ajuste conforme os canais de venda configurados no seu negócio.
        """
        source_name = order.get('source_name', '')
        channel_map = {
            'web'   : '203',   # loja virtual própria
            'pos'   : '201',   # ponto de venda físico
            'iphone': '203',
            'android': '203',
        }
        return channel_map.get(source_name, '203')
    
    # ================================================================ #
    # --------- ROTA: situacao/pedido/pedido_id ---------------------- #
    # ================================================================ #

    def beforeSend_situacao_pedido_pedido_id(self, data):
        """
        Decide qual ação executar na Shopify com base no código do status.
        Se o status não tiver ação mapeada (ex: apenas interno), cancela o envio.

        Códigos esperados em TBSTATUS.CDSTATUSEXTERNO:
        'pedido_enviado'   → cria fulfillment
        'pedido_cancelado' → cancela o pedido
        'pedido_fechado'   → fecha o pedido
        """
        codigo = data.get('codigo')

        if not codigo or codigo in [None, 'None', '']:
            # Status sem ação na Shopify — ignora silenciosamente
            self.atualWs['send'] = False
            return data

        self.atualWs['send'] = True
        return data

    # Observação (reasonNotes, até 256 chars) enviada à Shopify por código
    # de status. Para acrescentar um status novo, basta mapear aqui o texto.
    OBSERVACOES_STATUS = {
        'in_progress' : 'Em separação',
    }

    def afterSend_situacao_pedido_pedido_id(self, data):
        """
        Após processar o status:
        1. Executa a ação correspondente na Shopify
        2. Só deleta o registro de TBPEDIDOSSTATUS quando a ação deu certo —
           se falhar, o status fica na tabela e é reprocessado na próxima
           execução, em vez de ser perdido silenciosamente.
        """
        codigo     = self.current_data.get('codigo')
        pedido_id  = self.current_data.get('pedido_id')   # NÚMERO do pedido na Shopify (ex: 1021)
        softdib_id = self.current_data.get('softdib_id')  # IDTBPEDIDOSSTATUS

        # Cada ação devolve True apenas quando concluiu com sucesso.
        acoes = {
            'in_progress' : self._shopify_marcar_em_separacao,
        }

        acao = acoes.get(codigo)

        if acao is None:
            self.createLog('204', f'Status "{codigo}" sem ação mapeada (pedido {pedido_id}) — '
                                  f'TBPEDIDOSSTATUS {softdib_id} mantido')
            return data

        # Observação (texto livre) enviada à Shopify. Prioriza o que o ERP
        # gravou em TBPEDIDOSSTATUS.NMOBSERVACAO (ex: "O Pedido foi cadastrado
        # como teste Softdib em 20/10/2026 as 15:36"); se vier vazio, cai no
        # texto fixo por código; se também não houver, usa o padrão da ação.
        observacao = (self.current_data.get('observacao')
                      or self.OBSERVACOES_STATUS.get(codigo))

        sucesso = acao(pedido_id, observacao) if observacao else acao(pedido_id)

        if sucesso:
            self.getDb().delete(
                "TBPEDIDOSSTATUS",
                [f"IDTBPEDIDOSSTATUS = {softdib_id}"]
            )
            self.createLog('200', f'TBPEDIDOSSTATUS {softdib_id} removido — '
                                  f'pedido {pedido_id} ({codigo}) processado com sucesso')
        else:
            self.createLog('422', f'Ação "{codigo}" não concluída (pedido {pedido_id}) — '
                                  f'TBPEDIDOSSTATUS {softdib_id} mantido para reprocessar')

        return data

    def order_id(self, field):
        return self.current_order['order_number'] 

    def _shopify_buscar_pedido(self, pedido):
        """
        Busca um pedido na Shopify via GraphQL, com status financeiro, de
        fulfillment, itens e os fulfillment orders.

        pedido: número do pedido (ex: 1021 / '#1021'), id numérico interno,
                ou gid completo. Como a integração guarda o NÚMERO do pedido
                em CDPEDIDOEXTERNO, o caminho normal é resolver por número
                (filtro 'name:#...'). Um gid ou id interno é consultado direto.

        Retorna o dict do pedido (já achatado) ou None se não existir.
        """
        valor = str(pedido).strip()

        if valor.startswith('gid://'):
            data  = self.shopify_graphql.run(self.QUERY_FO_POR_ID, {'id': valor})
            order = (data or {}).get('order')
        else:
            # Trata sempre como NÚMERO do pedido (name). É o que está gravado.
            numero = valor.lstrip('#')
            data   = self.shopify_graphql.run(self.QUERY_FO_POR_NUMERO,
                                              {'query': f"name:#{numero}"})
            pedidos = (data or {}).get('orders') or []
            order   = pedidos[0] if pedidos else None

        if not order:
            self.createLog('404', f'Pedido {pedido} não encontrado na Shopify '
                                  f'(buscado por número via name:#...)')
            return None

        return order

    def _log_sem_fulfillment_order(self, pedido, order):
        """
        Vazio quase sempre é falta de scope (read_merchant_managed_
        fulfillment_orders / read_assigned_fulfillment_orders) — nesse caso a
        Shopify devolve [] sem erro — ou o pedido não tem itens faturáveis.
        A Shopify NÃO permite criar fulfillment order manualmente: eles são
        gerados no checkout. Diagnóstico pelos itens do pedido.
        """
        itens     = order.get('lineItems') or []
        com_frete = [i for i in itens if i.get('requiresShipping')]
        a_enviar  = sum(int(i.get('unfulfilledQuantity') or 0) for i in itens)

        if com_frete and a_enviar > 0:
            self.createLog('403', f'Pedido {pedido} ({order.get("name")}): {len(com_frete)} '
                                  f'item(ns) com frete e {a_enviar} unid. a enviar, mas '
                                  f'fulfillmentOrders=[]. É falta de scope do app Shopify: '
                                  f'reautorize com read_merchant_managed_fulfillment_orders '
                                  f'e read_assigned_fulfillment_orders.')
        else:
            self.createLog('204', f'Pedido {pedido} ({order.get("name")}) sem fulfillment '
                                  f'orders e sem item faturável — nada a separar. '
                                  f'status={order.get("displayFulfillmentStatus")}, '
                                  f'itens_com_frete={len(com_frete)}, a_enviar={a_enviar}')

    def _shopify_marcar_em_separacao(self, pedido_id, nota='Em separação'):
        """
        Marca os fulfillment orders de UM pedido como 'in progress'
        (= "em separação"), SEM criar fulfillment / SEM enviar. Sinaliza
        ao cliente que o pedido está sendo preparado.

        Só age em pedido PAGO e apenas nos fulfillment orders que ainda NÃO
        estão IN_PROGRESS — assim não re-reporta progresso no que já foi
        marcado. Se o pedido já estiver todo em separação, considera sucesso
        (True) para que o status saia da fila; se ainda não estiver pago,
        devolve False para reprocessar numa próxima execução.

        Usa fulfillmentOrderReportProgress. O único campo do input é
        reasonNotes (opcional, até 256 chars) — texto livre de contexto.

        pedido_id: NÚMERO do pedido na Shopify (ex: 1021), como está gravado
        em CDPEDIDOEXTERNO. A mutation exige o id do FulfillmentOrder, não do
        pedido — por isso resolvemos os FOs pelo número aqui.
        """
        order = self._shopify_buscar_pedido(pedido_id)

        if not order:
            return False

        # --- Filtro 1: só pedido PAGO ---
        financeiro = (order.get('displayFinancialStatus') or '').upper()
        if financeiro != 'PAID':
            self.createLog('402', f'Pedido {pedido_id} ({order.get("name")}) não está pago '
                                  f'(financial={financeiro or "?"}) — não marca em separação; '
                                  f'será reprocessado quando pagar.')
            return False

        fulfillment_orders = order.get('fulfillmentOrders') or []

        if not fulfillment_orders:
            self._log_sem_fulfillment_order(pedido_id, order)
            return False

        # --- Filtro 2: só os que ainda NÃO estão IN_PROGRESS ---
        # OPEN  → reportar progresso agora
        # IN_PROGRESS → já em separação, não repetir
        # CLOSED/CANCELLED/etc → nada a fazer
        a_marcar    = [fo for fo in fulfillment_orders if (fo.get('status') or '').upper() == 'OPEN']
        ja_separado = [fo for fo in fulfillment_orders if (fo.get('status') or '').upper() == 'IN_PROGRESS']

        if not a_marcar:
            if ja_separado:
                # Já estava em separação — trata como sucesso para sair da fila.
                self.createLog('200', f'Pedido {pedido_id} ({order.get("name")}) já em separação '
                                      f'(FOs IN_PROGRESS: {len(ja_separado)}) — nada a fazer.')
                return True

            self.createLog('400', f'Pedido {pedido_id} ({order.get("name")}) sem fulfillment '
                                  f'orders OPEN — status atuais: '
                                  f'{[(fo.get("id"), fo.get("status")) for fo in fulfillment_orders]}')
            return False

        sucesso = True

        for fo in a_marcar:
            resultado = self.shopify_graphql.mutate(
                self.MUTATION_FO_REPORTAR_PROGRESSO,
                {
                    'id'     : fo['id'],
                    'report' : {'reasonNotes': nota[:256]}   # respeita o limite de 256
                },
                result_path = 'fulfillmentOrderReportProgress'
            )

            # mutate() devolve {} e ja loga o motivo (HTTP, errors[] ou userErrors[])
            if not resultado:
                self.createLog('422', f'Falha ao marcar em separação (pedido {pedido_id}, FO {fo["id"]})')
                sucesso = False
                continue

            payload = resultado.get('fulfillmentOrderReportProgress') or {}
            status  = (payload.get('fulfillmentOrder') or {}).get('status')
            self.createLog('200', f'Pedido {pedido_id} FO {fo["id"]} → {status} (em separação)')

        return sucesso

    def _shopify_fulfil_order(self, pedido_id: str):
        """
        Marca o pedido como enviado/fulfillido na Shopify.

        Fluxo obrigatório:
        1. Busca o fulfillment_order_id via GET /orders/{id}/fulfillment_orders.json
        2. Cria o fulfillment via POST /fulfillments.json
        """
        # 1. Busca fulfillment_orders do pedido
        res = self.throttling(
            method = 'GET',
            rote   = f'orders/{pedido_id}/fulfillment_orders.json'
        )

        if not res.ok:
            self.createLog(res.status_code, f'Falha ao buscar fulfillment_orders do pedido {pedido_id}: {res.text}')
            return

        fulfillment_orders = res.json().get('fulfillment_orders', [])

        # Filtra apenas os que estão abertos (open) — podem ser fulfillidos
        # abertos = [fo for fo in fulfillment_orders if fo.get('status') == 'open']

        # if not abertos:
        #     self.createLog('400', f'Pedido {pedido_id} sem fulfillment_orders abertos — já enviado ou cancelado')
        #     return

        # 2. Cria o fulfillment para todos os itens abertos
        res = self.throttling(
            method = 'POST',
            rote   = 'fulfillments.json',
            json   = {
                'fulfillment': {
                    'line_items_by_fulfillment_order': [
                        {'fulfillment_order_id': fo['id']} for fo in abertos
                    ],
                    'notify_customer': True  # envia e-mail de envio ao cliente
                }
            }
        )

        if res.ok:
            self.createLog('200', f'Pedido {pedido_id} marcado como enviado na Shopify')
        else:
            self.createLog(res.status_code, f'Falha ao criar fulfillment para pedido {pedido_id}: {res.text}')

    def _shopify_cancel_order(self, pedido_id: str):
        """
        Cancela o pedido na Shopify.
        restock=True → devolve os itens ao estoque automaticamente.
        """
        res = self.throttling(
            method = 'POST',
            rote   = f'orders/{pedido_id}/cancel.json',
            json   = {
                'reason'          : 'customer',  # motivo padrão
                'restock'         : True,         # repõe estoque
                'email'           : True          # notifica o cliente
            }
        )

        if res.ok:
            self.createLog('200', f'Pedido {pedido_id} cancelado na Shopify')
        else:
            self.createLog(res.status_code, f'Falha ao cancelar pedido {pedido_id}: {res.text}')

    def _shopify_close_order(self, pedido_id: str):
        """
        Fecha o pedido na Shopify (arquiva sem cancelar).
        """
        res = self.throttling(
            method = 'POST',
            rote   = f'orders/{pedido_id}/close.json',
            json   = {}
        )

        if res.ok:
            self.createLog('200', f'Pedido {pedido_id} fechado na Shopify')
        else:
            self.createLog(res.status_code, f'Falha ao fechar pedido {pedido_id}: {res.text}')

    def _pagamentos_papapa(self, condpag, item):
        if 'PagBank - Cartão de Crédito' in condpag:
            busca_condpag = self.select("SELECT CDCONDPGTO FROM TBCONDPGTO WHERE DSCONDPGTO='CARTAO CREDITO 1X - PAGBANK'")
        if 'PagBank - PIX e Boleto' in condpag:
            busca_condpag = self.select("SELECT CDCONDPGTO FROM TBCONDPGTO WHERE DSCONDPGTO='PIX - PAGBANK'")

        if busca_condpag:
            return busca_condpag[0]['CDCONDPGTO']
        
        condpag=220

        return condpag

    def _nsu_papapa(self, transacoes, item):
        #lista_pagamentos = res.json().get('fulfillment_orders', [])
        
        nsu = [item.get('payment_id') for item in transacoes if item.get('status') == 'SUCCESS']

        return nsu[0] if nsu else ''

    def _vendedor_papapa(self, vendedor, item):
        return 'V00044'
    
    def _origem_papapa(self, vendedor, item):
        return '102'

    def _envios_papapa(self, envio, item):
        return envio['code']

    def desconto_papapa(self, order, item):
        """
        VLDESCONTO = total_discounts - total_tax. O integration do map so lê
        caminho (nao subtrai), por isso o campo vem com 'root' e a conta e feita
        aqui. O _rest_shape ja achatou os dois para o valor escalar.
        Sem total_discounts o resultado fica negativo (-total_tax).
        """
        def valor(chave):
            try:
                return float(order.get(chave) or 0)
            except (TypeError, ValueError):
                return 0.0

        return f"{valor('total_discounts') - valor('total_tax'):.2f}"

    # ================================================================ #
    # --------- TRATAMENTO DE RESPOSTA ------------------------------- #
    # ================================================================ #

    def response_code_201(self, response):
        """
        Shopify retorna 201 Created no POST.
        Delega para o gn_api que grava/atualiza TBRELACIONAID.
        """
        return super().response_code_201(response)

    def response_code_200(self, response):
        """
        Shopify retorna 200 OK no PUT.
        Reutiliza o fluxo do 201 para manter TBRELACIONAID atualizado.
        """
        return super().response_code_201(response)

    def response_code_422(self, response):
        """
        Erro de validação (campo inválido, obrigatório ausente etc).
        Loga o detalhe completo para facilitar debug.
        """
        self.response_log(response)
        return False

    def response_code_400(self, response):
        self.response_log(response)
        return False


    def define_sd_request(self,rota='/integracao/pedido/authentication'):
        """ 
            Monta a estrutura base de headers e paramentros para consumir ou enivar dados para api softdib
        """
        
        prot = "http://"
        base = "192.168.1.3" if self.debug else self.softdib_ip
        port = f':{self.softdib_port or 3000}'#.format()
        base_url = f"{prot}{base}{port}"
        #""" .format(
        #            prot=prot
        #        ,   base=base
        #        ,   port=port
        #) """
        
        auth = Request(
            base_url  =   base_url 
        )
        auth_res = auth.doRequest(
            rote  = rota,
            method  =   "post", 
            json    =
            {
                    "user"      : "SAC"
                ,   "lkgp"      : self.nmlkgrupo
                ,   "banco"     : self.nmbanco
                ,   "apiname"   : self.apiName
                ,   "empresa"   : int(self.cdempresa)
                ,   "filial"    : int(self.cdfilial)
            }
        )
        
        if auth_res.status_code != 200 :
            print('Falha ao autenticar na Softdib')
            print(f"Falhou: {auth_res.status_code} - {auth_res.text} - {auth_res.reason}")#.format(auth_res.status_code))
            exit()
            
        
        self.softdib_request = Request(
            base_url = base_url,
            headers = {
                "Content-Type": "application/json"
                ,"Accept": "application/json"
                ,"x-access-token": auth_res.text.replace('"','')
            }
        )
        print(self.softdib_request)
        
        



    # ================================================================ #
    # --------- DESTRUTOR -------------------------------------------- #
    # ================================================================ #

    def __del__(self):
        self.createLog('999', 'Shopify — Final da exportação')

