import json
import requests
import logging
import os
import csv
import threading
import time
import base64
import io
import datetime
from odoo import api, registry
from odoo import models, fields, api, _
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)

class ShopifyConfig(models.Model):
    _name = 'shopify.config'
    _description = 'Configuración de Shopify'

    name = fields.Char(string='Descripción', required=True, default='Tienda Principal')
    shop_url = fields.Char(string='URL de la Tienda (.myshopify.com)', required=True)
    api_key = fields.Char(string='Client ID (API Key)', required=False)
    api_secret = fields.Char(string='Client Secret', required=False)
    access_token = fields.Char(string='Admin API Access Token (shpat_...)')
    state = fields.Selection([
        ('draft', 'Borrador'),
        ('confirmed', 'Confirmado')
    ], string='Estado', default='draft')
    
    access_token_expiry = fields.Datetime(string='Expiración del Token', readonly=True)
    # Sincronización de Inventario
    simulation_line_ids = fields.One2many('shopify.sku.simulation.line', 'config_id', string='Líneas de Simulación')
    all_line_ids = fields.One2many('shopify.sku.simulation.line', 'config_id', string='Todas las Líneas')
    is_syncing = fields.Boolean(string='Sincronizando', default=False)
    sync_progress = fields.Float(string='Progreso de Sincronización', default=0.0)
    sku_simulation_log = fields.Text(string='Log de Proceso', default='Listo para iniciar.')

    # Sincronización de Pedidos
    last_order_sync_date = fields.Datetime(string='Última Sincronización de Pedidos', help="Fecha desde la cual se buscarán pedidos nuevos.")
    shopify_import_cutoff_date = fields.Datetime(
        string='Fecha de Corte para Importación',
        default=fields.Datetime.now,
        help="Pedidos creados en Shopify ANTES de esta fecha se importarán pero sus entregas de salida se cancelarán automáticamente para no descontar inventario histórico."
    )
    order_pricelist_id = fields.Many2one('product.pricelist', string='Lista de Precios Shopify')
    order_journal_id = fields.Many2one('account.journal', string='Diario de Pagos Shopify')
    sales_team_id = fields.Many2one('crm.team', string='Equipo de Ventas Shopify')
    default_warehouse_id = fields.Many2one('stock.warehouse', string='Almacén por Defecto')
    auto_import_orders = fields.Boolean(
        string='Importación Automática (Cron)', 
        default=False,
        help="Activa la búsqueda y descarga automatizada de nuevos pedidos de Shopify a Odoo en segundo plano. No afecta inventarios directamente, solo crea la orden de venta."
    )
    auto_create_draft_invoice = fields.Boolean(string='Crear Factura Borrador Automáticamente', default=False)
    auto_workflow_payment = fields.Boolean(
        string='Auto-Validar Factura y Registrar Pago', 
        default=False,
        help="Si está activo, al crear la factura borrador se publicará automáticamente y se le registrará el pago (para pedidos ya pagados en Shopify)."
    )
    payment_journal_id = fields.Many2one(
        'account.journal', 
        string='Diario de Pagos Shopify', 
        domain="[('type', 'in', ['bank', 'cash'])]",
        help="El diario donde caerá el dinero de los pagos automáticos de Shopify."
    )
    
    # ── Control de Reembolsos y Devoluciones ──
    auto_process_refunds = fields.Boolean(
        string='Automatizar Devoluciones y Reembolsos',
        default=False,
        help="Si está activo, al detectar un reembolso en Shopify, Odoo creará automáticamente la Nota de Crédito y la Transferencia Inversa de stock."
    )
    shopify_sandbox_mode = fields.Boolean(
        string='Modo Sandbox / Pruebas de Reembolso',
        default=True,
        help="CRÍTICO DE SEGURIDAD: Si está activo, el conector SOLO procesará automatizaciones de reembolsos en pedidos que contengan SKUs que incluyan la palabra 'PRUEBA'. Protege al 100% las órdenes reales de clientes en vivo."
    )

    auto_sync_products = fields.Boolean(
        string='Sincronización Automática de Catálogo',
        default=False,
        help="Si está activo, al modificar un producto en Odoo (precio, nombre, etc.) se enviará el cambio automáticamente a Shopify en tiempo real. Si está desmarcado, toda actualización del catálogo será manual mediante el botón 'Publicar / Actualizar en Shopify'."
    )
    order_sync_interval = fields.Selection([
        ('5', 'Cada 5 minutos'),
        ('15', 'Cada 15 minutos'),
        ('30', 'Cada 30 minutos'),
        ('60', 'Cada hora'),
        ('120', 'Cada 2 horas'),
    ], string='Frecuencia de Importación', default='30', required=True,
       help="Intervalo de tiempo con el que se ejecuta la importación de pedidos en segundo plano. Frecuencia recomendada para producción: 15 o 30 minutos.")

    # Sincronización de Imágenes en segundo plano
    auto_import_images = fields.Boolean(
        string='Auto-Descargar Imágenes (Cron)', 
        default=False,
        help="Busca automáticamente productos que no tengan imagen en Odoo y descarga sus portadas desde Shopify de forma transparente, ejecutándose en segundo plano por lotes pequeños."
    )
    image_sync_interval = fields.Selection([
        ('1', 'Cada Hora'),
        ('2', 'Cada 2 Horas'),
        ('6', 'Cada 6 Horas'),
        ('12', 'Cada 12 Horas'),
        ('24', 'Cada 24 Horas / Diario'),
    ], string='Frecuencia de Imágenes', default='2', required=True,
       help="Frecuencia con la que se buscarán nuevas imágenes de Shopify. Se recomienda 2 o 6 horas para un rendimiento equilibrado.")
   
    # ── Configuración de Cron de Sincronización de Inventario ──
    cron_sync_inventory_active = fields.Boolean(
        string='Sincronizar Stock automáticamente (Cron)',
        default=False,
        help="CRÍTICO: Activa la actualización automática del inventario desde Odoo hacia Shopify en segundo plano. Odoo envía el stock físico real para evitar que los clientes compren productos agotados en línea."
    )
    cron_sync_inventory_interval = fields.Selection([
        ('15', 'Cada 15 minutos'),
        ('30', 'Cada 30 minutos'),
        ('60', 'Cada hora'),
        ('120', 'Cada 2 horas'),
        ('360', 'Cada 6 horas'),
        ('720', 'Cada 12 horas'),
        ('1440', 'Cada 24 horas / Diario'),
    ], string='Frecuencia de Stock', default='720', required=True,
       help="Intervalo de ejecución para actualizar las existencias en Shopify. Frecuencia recomendada: 30 o 60 minutos en producción.")

    # ── Estados y Próximas Ejecuciones de Crons ──
    cron_orders_status = fields.Char(string='Estado del Cron de Pedidos', compute='_compute_cron_statuses')
    cron_inventory_status = fields.Char(string='Estado del Cron de Stock', compute='_compute_cron_statuses')
    cron_images_status = fields.Char(string='Estado del Cron de Imágenes', compute='_compute_cron_statuses')
    cron_tokens_status = fields.Char(string='Estado del Cron de Tokens', compute='_compute_cron_statuses')

    def _compute_cron_statuses(self):
        for record in self:
            # 1. Cron de Pedidos
            cron_orders = self.env.ref('hazard_shopify.ir_cron_shopify_import_orders', raise_if_not_found=False)
            if cron_orders and cron_orders.sudo().active:
                nextcall = fields.Datetime.context_timestamp(record, cron_orders.sudo().nextcall)
                freq = f"cada {cron_orders.sudo().interval_number} "
                if cron_orders.sudo().interval_type == 'minutes':
                    freq += "minutos"
                elif cron_orders.sudo().interval_type == 'hours':
                    freq += "horas"
                else:
                    freq += cron_orders.sudo().interval_type
                record.cron_orders_status = f"🟢 Activo | Frecuencia: {freq} | Próxima ejecución: {nextcall.strftime('%Y-%m-%d %H:%M:%S')}"
            else:
                record.cron_orders_status = "🔴 Inactivo"

            # 2. Cron de Inventario
            cron_inventory = self.env.ref('hazard_shopify.ir_cron_shopify_sync_inventory', raise_if_not_found=False)
            if cron_inventory and cron_inventory.sudo().active:
                nextcall = fields.Datetime.context_timestamp(record, cron_inventory.sudo().nextcall)
                freq = f"cada {cron_inventory.sudo().interval_number} "
                if cron_inventory.sudo().interval_type == 'minutes':
                    freq += "minutos"
                elif cron_inventory.sudo().interval_type == 'hours':
                    freq += "horas"
                else:
                    freq += cron_inventory.sudo().interval_type
                record.cron_inventory_status = f"🟢 Activo | Frecuencia: {freq} | Próxima ejecución: {nextcall.strftime('%Y-%m-%d %H:%M:%S')}"
            else:
                record.cron_inventory_status = "🔴 Inactivo"

            # 3. Cron de Imágenes
            cron_images = self.env.ref('hazard_shopify.ir_cron_shopify_auto_image_sync', raise_if_not_found=False)
            if cron_images and cron_images.sudo().active:
                nextcall = fields.Datetime.context_timestamp(record, cron_images.sudo().nextcall)
                freq = f"cada {cron_images.sudo().interval_number} "
                if cron_images.sudo().interval_type == 'minutes':
                    freq += "minutos"
                elif cron_images.sudo().interval_type == 'hours':
                    freq += "horas"
                else:
                    freq += cron_images.sudo().interval_type
                record.cron_images_status = f"🟢 Activo | Frecuencia: {freq} | Próxima ejecución: {nextcall.strftime('%Y-%m-%d %H:%M:%S')}"
            else:
                record.cron_images_status = "🔴 Inactivo"

            # 4. Cron de Tokens
            cron_tokens = self.env.ref('hazard_shopify.ir_cron_shopify_refresh_tokens', raise_if_not_found=False)
            if cron_tokens and cron_tokens.sudo().active:
                nextcall = fields.Datetime.context_timestamp(record, cron_tokens.sudo().nextcall)
                record.cron_tokens_status = f"🟢 Activo | Próxima renovación: {nextcall.strftime('%Y-%m-%d %H:%M:%S')}"
            else:
                record.cron_tokens_status = "🔴 Inactivo"
    # Campo para carga de CSV
    csv_file = fields.Binary(string='Archivo SKU Reference (CSV)', help="Sube el archivo 'REV2 SKU .csv' aquí")
    csv_filename = fields.Char(string='Nombre del Archivo')
    
    # Campo para Ubicación de Shopify
    shopify_location_id = fields.Char(string='Shopify Location ID', help="ID de la sucursal en Shopify para sincronizar inventario")
    shopify_location_name = fields.Char(string='Sucursal Seleccionada', readonly=True)
    
    sku_simulation_log = fields.Text(string='Log de Simulación de SKUs', readonly=True)
    simulation_line_ids = fields.One2many('shopify.sku.simulation.line', 'config_id', string='Líneas de Simulación')
    location_mapping_ids = fields.One2many('shopify.location.mapping', 'config_id', string='Mapeo de Sucursales')
    location_ids = fields.Many2many('stock.location', string='Ubicaciones de Odoo para Stock', domain=[('usage', '=', 'internal')])

    # Campos para reporte SKU + Código de Barras
    sku_barcode_report_file = fields.Binary(string='Reporte SKU/Barcode', readonly=True)
    sku_barcode_report_filename = fields.Char(string='Nombre Reporte SKU/Barcode', readonly=True)

    # ── EAN-13 Barcode Generator ────────────────────────────────────────────
    barcode_prefix = fields.Char(
        string='Prefijo empresa (6 dígitos)',
        default='200001',
        help='Prefijo fijo que identifica a la empresa. El dígito "2" reserva uso interno según GS1.'
    )
    barcode_is_pushing = fields.Boolean(string='Subiendo Barcodes', default=False)
    barcode_pushed_count = fields.Integer(
        string='Subidos ✓', compute='_compute_barcode_counts', store=False
    )
    barcode_pending_count = fields.Integer(
        string='Pendientes', compute='_compute_barcode_counts', store=False
    )
    barcode_error_count = fields.Integer(
        string='Errores', compute='_compute_barcode_counts', store=False
    )
    barcode_none_count = fields.Integer(
        string='Sin generar', compute='_compute_barcode_counts', store=False
    )
    barcode_progress = fields.Float(
        string='Progreso (%)', compute='_compute_barcode_counts', store=False
    )

    # ── Sincronización de Costos ──────────────────────────────────────────────
    cost_line_ids = fields.One2many(
        'shopify.cost.line', 'config_id', string='Líneas de Costo'
    )
    cost_pending_count = fields.Integer(
        string='Pendientes (Costos)', compute='_compute_cost_counts', store=False
    )
    cost_done_count = fields.Integer(
        string='Subidos ✓ (Costos)', compute='_compute_cost_counts', store=False
    )
    cost_error_count = fields.Integer(
        string='Errores (Costos)', compute='_compute_cost_counts', store=False
    )
    cost_is_fetching = fields.Boolean(string='Consultando Costos', default=False)
    cost_is_pushing = fields.Boolean(string='Aplicando Costos', default=False)

    @api.depends('cost_line_ids.state')
    def _compute_cost_counts(self):
        for record in self:
            lines = record.cost_line_ids
            record.cost_pending_count = len(lines.filtered(lambda l: l.state == 'pending'))
            record.cost_done_count = len(lines.filtered(lambda l: l.state == 'done'))
            record.cost_error_count = len(lines.filtered(lambda l: l.state == 'error'))

    @api.depends('simulation_line_ids.barcode_sync_state')
    def _compute_barcode_counts(self):
        for record in self:
            lines = record.simulation_line_ids
            total = len(lines)
            done    = len(lines.filtered(lambda l: l.barcode_sync_state == 'done'))
            pending = len(lines.filtered(lambda l: l.barcode_sync_state == 'pending'))
            errors  = len(lines.filtered(lambda l: l.barcode_sync_state == 'error'))
            none_st = len(lines.filtered(lambda l: l.barcode_sync_state == 'none'))
            record.barcode_pushed_count  = done
            record.barcode_pending_count = pending
            record.barcode_error_count   = errors
            record.barcode_none_count    = none_st
            record.barcode_progress      = round(done / total * 100, 1) if total else 0.0

    @staticmethod
    def _ean13_check(twelve: str) -> int:
        """Calcula el dígito verificador EAN-13 estándar."""
        total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(twelve))
        return (10 - total % 10) % 10

    def _get_next_barcode_counter(self, prefix):
        """
        Calcula el siguiente número consecutivo (contador de 6 dígitos) libre.
        Escanea tanto los productos reales en Odoo como las líneas de simulación
        (tanto barcode de Shopify como generated_barcode de Odoo) para evitar colisiones.
        """
        self.ensure_one()
        # Buscar todos los barcodes activos/inactivos en Odoo que empiecen con el prefijo
        existing_codes = [bc for bc in self.env['product.product'].with_context(active_test=False).search([
            ('barcode', '=like', prefix + '%')
        ]).mapped('barcode') if bc]

        # Buscar en las líneas de simulación (tanto el barcode original como el generado)
        for line in self.simulation_line_ids:
            if line.barcode and line.barcode.startswith(prefix) and len(line.barcode) == 13:
                existing_codes.append(line.barcode)
            if line.generated_barcode and line.generated_barcode.startswith(prefix) and len(line.generated_barcode) == 13:
                existing_codes.append(line.generated_barcode)

        existing_counters = []
        for bc in existing_codes:
            if len(bc) == 13:
                try:
                    existing_counters.append(int(bc[6:12]))
                except ValueError:
                    pass

        return max(existing_counters) + 1 if existing_counters else 1

    def action_generate_ean13(self):
        """
        Genera códigos EAN-13 únicos para todas las variantes sin barcode.
        Prefijo empresa (6 dig) + contador secuencial (6 dig) + verificador (1 dig).
        Idempotente: omite variantes que ya tienen un código subido exitosamente a Shopify.
        """
        self.ensure_one()
        prefix = (self.barcode_prefix or '200001')[:6].zfill(6)

        # Consideramos para generar/regenerar cualquier línea que no tenga código original de Shopify
        # y cuyo estado de sincronización de barcode no esté confirmado como exitoso ('done')
        lines_without = self.simulation_line_ids.filtered(
            lambda l: not l.barcode and l.barcode_sync_state != 'done'
        )

        if not lines_without:
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': 'Sin productos pendientes',
                    'message': 'Todos los productos ya cuentan con un código de barras (Shopify o EAN-13 subido).',
                    'type': 'warning',
                    'sticky': False,
                }
            }

        counter = self._get_next_barcode_counter(prefix)

        vals_list = []
        for line in lines_without:
            item_str = str(counter).zfill(6)
            base  = prefix + item_str
            check = self._ean13_check(base)
            vals_list.append((line.id, base + str(check)))
            counter += 1

        for line_id, ean13 in vals_list:
            self.env['shopify.sku.simulation.line'].browse(line_id).write({
                'generated_barcode': ean13,
                'barcode_sync_state': 'pending',
                'barcode_sync_error': False,
            })

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'EAN-13 Generados',
                'message': f'Se generaron/corrigieron {len(vals_list)} códigos EAN-13 de forma segura y sin duplicados.',
                'type': 'success',
                'sticky': False,
            }
        }

    def _ensure_access_token(self):
        """
        Verifica y renueva el access token de Shopify (Client Credentials Grant).

        El token del Dev Dashboard dura exactamente 24h (86,399 seg).
        Este método lo renueva automáticamente con 5 minutos de margen antes
        de que expire, o si no existe. Cualquier método que llame a la API
        debe usar este helper en lugar de acceder a self.access_token directamente.
        """
        self.ensure_one()
        now = fields.Datetime.now()
        # Margen de seguridad: renovar 5 minutos ANTES de expirar
        safety_margin = datetime.timedelta(minutes=5)
        token_is_valid = (
            self.access_token
            and self.access_token_expiry
            and self.access_token_expiry > (now + safety_margin)
        )
        if not token_is_valid:
            if self.api_key and self.api_secret:
                _logger.info(
                    "Token de Shopify ausente o próximo a expirar. Renovando automáticamente "
                    "(expiry=%s, now=%s)...", self.access_token_expiry, now
                )
                self.action_get_access_token()
                _logger.info("Token renovado exitosamente. Nuevo expiry: %s", self.access_token_expiry)
            else:
                # Sin credenciales no hay forma de renovar
                raise UserError(
                    "El token de Shopify ha expirado o no existe, y no hay Client ID / "
                    "Client Secret configurados para renovarlo automáticamente.\n\n"
                    "Configura el Client ID y Client Secret del Dev Dashboard de Shopify "
                    "y luego presiona 'Generar Token'."
                )
        return self.access_token

    def action_push_barcodes_to_shopify(self):
        """Lanza la subida de barcodes en un hilo background (por lotes de 50)."""
        self.ensure_one()
        if self.barcode_is_pushing:
            raise UserError("Ya hay un proceso de barcodes en curso. Espera a que termine.")
        pending = self.simulation_line_ids.filtered(
            lambda l: l.is_selected and l.barcode_sync_state == 'pending' and l.generated_barcode
        )
        if not pending:
            raise UserError("No hay barcodes pendientes y seleccionados (Sel?). Genera los EAN-13 y marca las casillas de los que deseas subir.")

        # Activar el estado de sincronización global para visualizar la barra de progreso y botón de desbloqueo
        self.write({
            'barcode_is_pushing': True,
            'is_syncing': True,
            'sync_progress': 0.0
        })
        self.env.cr.commit()

        import threading
        thread = threading.Thread(
            target=self._push_barcodes_background,
            args=(self.id, self.env.cr.dbname),
            daemon=True,
        )
        thread.start()

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'Proceso iniciado',
                'message': f'Subiendo {len(pending)} barcodes seleccionados a Shopify en segundo plano. Refresca la página para ver el progreso.',
                'type': 'info',
                'sticky': True,
            }
        }

    @api.model
    def _push_barcodes_background(self, config_id, dbname):
        """
        Worker background: sube barcodes a Shopify en lotes de 50.
        Commit por línea → nunca se pierde progreso ante un fallo.
        """
        import odoo
        import time
        from odoo import fields
        BATCH_SIZE = 50
        SLEEP_BETWEEN = 0.6  # Respetar rate limit Shopify (~2 req/s)

        try:
            registry = odoo.modules.registry.Registry(dbname)
            with registry.cursor() as cr:
                env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
                config = env['shopify.config'].browse(config_id)

                headers = {
                    'X-Shopify-Access-Token': config.access_token,
                    'Content-Type': 'application/json',
                }
                base_url = f"https://{config.shop_url}/admin/api/2023-10"

                pending_lines = config.simulation_line_ids.filtered(
                    lambda l: l.is_selected and l.barcode_sync_state == 'pending' and l.generated_barcode
                )

                total = len(pending_lines)
                if not total:
                    config.write({'barcode_is_pushing': False, 'is_syncing': False})
                    cr.commit()
                    return

                # Inicializar barra de progreso y bitácora
                config.write({
                    'is_syncing': True,
                    'sync_progress': 0.0,
                    'sku_simulation_log': f"📤 [EAN-13] Iniciando subida masiva de códigos de barras (EAN-13) a Shopify para {total} variantes..."
                })
                cr.commit()

                success_count = 0
                error_count = 0
                current_index = 0

                for i in range(0, total, BATCH_SIZE):
                    batch = pending_lines[i:i + BATCH_SIZE]
                    for line in batch:
                        current_index += 1
                        progress = (current_index / total) * 100.0

                        # Escribir avance en la bitácora
                        config.write({
                            'sku_simulation_log': f"⏳ [EAN-13] [{current_index}/{total}] Subiendo código de barras para: {line.product_name} (SKU: {line.old_sku})...",
                            'sync_progress': progress
                        })
                        cr.commit()

                        try:
                            url = f"{base_url}/variants/{line.shopify_variant_id}.json"
                            payload = {
                                'variant': {
                                    'id': line.shopify_variant_id,
                                    'barcode': line.generated_barcode,
                                }
                            }
                            resp = requests.put(url, json=payload, headers=headers, timeout=10)
                            if resp.status_code == 200:
                                line.write({'barcode_sync_state': 'done', 'barcode_sync_error': False})
                                success_count += 1
                            elif resp.status_code == 429:
                                # Rate limit → espera y reintenta una vez
                                time.sleep(2)
                                resp2 = requests.put(url, json=payload, headers=headers, timeout=10)
                                if resp2.status_code == 200:
                                    line.write({'barcode_sync_state': 'done', 'barcode_sync_error': False})
                                    success_count += 1
                                else:
                                    line.write({'barcode_sync_state': 'error',
                                                'barcode_sync_error': f"Rate limit + HTTP {resp2.status_code}"})
                                    error_count += 1
                            else:
                                line.write({
                                    'barcode_sync_state': 'error',
                                    'barcode_sync_error': f"HTTP {resp.status_code}: {resp.text[:200]}",
                                })
                                error_count += 1
                        except Exception as e:
                            line.write({'barcode_sync_state': 'error', 'barcode_sync_error': str(e)[:200]})
                            error_count += 1

                        cr.commit()  # ← commit por línea, nunca se pierde progreso
                        time.sleep(SLEEP_BETWEEN)

                # Finalización exitosa
                timestamp_str = fields.Datetime.context_timestamp(config, fields.Datetime.now()).strftime('%Y-%m-%d %H:%M:%S')
                config.write({
                    'barcode_is_pushing': False,
                    'is_syncing': False,
                    'sync_progress': 100.0,
                    'sku_simulation_log': f"✅ [EAN-13] Subida masiva de códigos de barras completada el {timestamp_str}.\nResultados → Exitosas: {success_count} | Errores/Omitidas: {error_count}."
                })
                cr.commit()

        except Exception as e:
            _logger.error("_push_barcodes_background falló globalmente: %s", e)
            try:
                registry = odoo.modules.registry.Registry(dbname)
                with registry.cursor() as cr2:
                    env2 = odoo.api.Environment(cr2, odoo.SUPERUSER_ID, {})
                    env2['shopify.config'].browse(config_id).write({
                        'barcode_is_pushing': False,
                        'is_syncing': False
                    })
                    cr2.commit()
            except Exception:
                pass

    def action_retry_barcode_errors(self):
        """Resetea todos los errores de barcode a 'pending' para reintentarlos."""
        self.ensure_one()
        errored = self.simulation_line_ids.filtered(lambda l: l.barcode_sync_state == 'error')
        if not errored:
            raise UserError("No hay errores de barcode para reintentar.")
        errored.write({'barcode_sync_state': 'pending', 'barcode_sync_error': False})
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'Listos para reintentar',
                'message': f'{len(errored)} errores marcados como pendientes. Vuelve a lanzar "Subir a Shopify".',
                'type': 'warning',
                'sticky': False,
            }
        }

    def action_mark_barcodes_done(self):
        """
        Marca como 'Subido ✓' todos los items pendientes que ya tienen EAN-13 generado.
        Útil cuando el proceso en segundo plano completó la subida a Shopify
        pero no actualizó el estado en Odoo (problema de hilo/cursor).
        """
        self.ensure_one()
        pending = self.simulation_line_ids.filtered(
            lambda l: l.barcode_sync_state == 'pending' and l.generated_barcode
        )
        if not pending:
            raise UserError("No hay barcodes pendientes con EAN-13 generado.")
        pending.write({'barcode_sync_state': 'done', 'barcode_sync_error': False})
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': '✓ Marcados como Subidos',
                'message': (
                    f'{len(pending)} barcodes marcados como subidos correctamente. '
                    f'Úsalo solo si ya verificaste que los códigos están en Shopify.'
                ),
                'type': 'success',
                'sticky': False,
            }
        }

    def action_export_barcode_labels(self):
        """Exporta CSV con EAN-13 generados, listo para impresora de etiquetas Dymo/Zebra."""
        self.ensure_one()
        ready = self.simulation_line_ids.filtered(lambda l: l.generated_barcode)
        if not ready:
            raise UserError("Genera los EAN-13 primero.")

        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(['PRODUCTO', 'VARIANTE', 'SKU', 'EAN-13', 'ESTADO EN SHOPIFY'])
        state_label = {'none': 'Sin generar', 'pending': 'Pendiente', 'done': 'Subido', 'error': 'Error'}
        for line in ready:
            writer.writerow([
                line.product_name or '',
                line.variant_name or '',
                line.old_sku or '',
                line.generated_barcode,
                state_label.get(line.barcode_sync_state, ''),
            ])
        csv_content = output.getvalue()
        output.close()

        fname = f"Etiquetas_EAN13_Hazard_{fields.Date.today()}.csv"
        self.write({
            'sku_barcode_report_file': base64.b64encode(csv_content.encode('utf-8-sig')),
            'sku_barcode_report_filename': fname,
        })
        return {
            'type': 'ir.actions.act_url',
            'url': (f'/web/content/?model=shopify.config&id={self.id}'
                    f'&field=sku_barcode_report_file&download=true&filename={fname}'),
            'target': 'self',
        }

    def action_export_inventory_excel(self):
        """
        Exporta Excel con 2 hojas:
          - Hoja 1 'Inventario': productos con SKU, barcode anterior y EAN-13 generado
          - Hoja 2 'Próximos Disponibles': siguientes 50 EAN-13 listos para nuevos productos
        """
        self.ensure_one()
        try:
            import openpyxl
            from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
        except ImportError:
            raise UserError("Falta la librería openpyxl. Contacta al administrador del sistema.")

        ready = self.simulation_line_ids.filtered(lambda l: l.generated_barcode)
        if not ready:
            raise UserError("Genera los EAN-13 primero.")

        wb = openpyxl.Workbook()

        # ── Estilos ────────────────────────────────────────────
        header_font        = Font(bold=True, color='FFFFFF', size=11)
        header_fill_blue   = PatternFill('solid', fgColor='2563EB')
        header_fill_green  = PatternFill('solid', fgColor='16A34A')
        center = Alignment(horizontal='center', vertical='center')
        left   = Alignment(horizontal='left',   vertical='center')
        thin   = Side(style='thin', color='CCCCCC')
        border = Border(left=thin, right=thin, top=thin, bottom=thin)

        state_label = {
            'none': 'Sin generar', 'pending': 'Pendiente',
            'done': 'Subido ✓',    'error': 'Error',
        }

        # ════════════════════════════════════════════════════════
        # HOJA 1 — Inventario completo
        # ════════════════════════════════════════════════════════
        ws1 = wb.active
        ws1.title = 'Inventario'
        ws1.row_dimensions[1].height = 22

        headers1   = ['PRODUCTO', 'VARIANTE', 'SKU', 'BARCODE ANTERIOR', 'EAN-13 GENERADO', 'ESTADO']
        col_widths1 = [45, 18, 30, 22, 20, 16]
        for col, (h, w) in enumerate(zip(headers1, col_widths1), start=1):
            cell = ws1.cell(row=1, column=col, value=h)
            cell.font      = header_font
            cell.fill      = header_fill_blue
            cell.alignment = center
            cell.border    = border
            ws1.column_dimensions[cell.column_letter].width = w

        for row_num, line in enumerate(ready, start=2):
            state_txt = state_label.get(line.barcode_sync_state, '')
            row_data  = [
                line.product_name  or '',
                line.variant_name  or '',
                line.old_sku       or '',
                line.barcode       or '',
                line.generated_barcode or '',
                state_txt,
            ]
            for col, val in enumerate(row_data, start=1):
                cell = ws1.cell(row=row_num, column=col, value=val)
                cell.alignment = left
                cell.border    = border
                if line.barcode_sync_state == 'done':
                    cell.fill = PatternFill('solid', fgColor='DCFCE7')   # verde
                elif line.barcode_sync_state == 'error':
                    cell.fill = PatternFill('solid', fgColor='FEE2E2')   # rojo
                elif line.barcode_sync_state == 'pending':
                    cell.fill = PatternFill('solid', fgColor='FEF9C3')   # amarillo

        ws1.freeze_panes = 'A2'

        # ════════════════════════════════════════════════════════
        # HOJA 2 — Próximos 50 EAN-13 disponibles
        # ════════════════════════════════════════════════════════
        ws2 = wb.create_sheet(title='Próximos Disponibles')
        ws2.row_dimensions[1].height = 36

        prefix = (self.barcode_prefix or '200001')[:6].zfill(6)
        next_counter = self._get_next_barcode_counter(prefix)

        # Instrucción general
        ws2['A1'] = (
            f'PRÓXIMOS EAN-13 DISPONIBLES  |  Prefijo: {prefix}  |  '
            f'Úsalos EN ORDEN para nuevos productos. '
            f'No asignes barcodes manualmente en Shopify — usa el módulo de Odoo.'
        )
        ws2.merge_cells('A1:E1')
        ws2['A1'].font      = Font(bold=True, color='92400E', size=10)
        ws2['A1'].fill      = PatternFill('solid', fgColor='FEF3C7')
        ws2['A1'].alignment = Alignment(horizontal='left', vertical='center', wrap_text=True)

        headers2   = ['#', 'EAN-13 RESERVADO', 'PRODUCTO (llenar al usar)', 'VARIANTE (llenar al usar)', 'FECHA DE USO']
        col_widths2 = [5, 20, 42, 22, 16]
        for col, (h, w) in enumerate(zip(headers2, col_widths2), start=1):
            cell = ws2.cell(row=2, column=col, value=h)
            cell.font      = header_font
            cell.fill      = header_fill_green
            cell.alignment = center
            cell.border    = border
            ws2.column_dimensions[cell.column_letter].width = w

        for i in range(500):
            item_str = str(next_counter + i).zfill(6)
            base      = prefix + item_str
            check     = self._ean13_check(base)
            ean13     = base + str(check)

            rn = i + 3
            num_cell = ws2.cell(row=rn, column=1, value=i + 1)
            num_cell.alignment = center
            num_cell.border    = border

            ean_cell = ws2.cell(row=rn, column=2, value=ean13)
            ean_cell.font      = Font(bold=True, name='Courier New', size=11)
            ean_cell.alignment = center
            ean_cell.fill      = PatternFill('solid', fgColor='F0FDF4')
            ean_cell.border    = border

            for col in range(3, 6):
                ws2.cell(row=rn, column=col).border = border

        ws2.freeze_panes = 'A3'

        # ── Serializar y adjuntar ──────────────────────────────
        buf = io.BytesIO()
        wb.save(buf)
        buf.seek(0)

        fname = f"Hazard_Inventario_EAN13_{fields.Date.today()}.xlsx"
        self.write({
            'sku_barcode_report_file':     base64.b64encode(buf.read()),
            'sku_barcode_report_filename': fname,
        })
        buf.close()

        return {
            'type': 'ir.actions.act_url',
            'url': (f'/web/content/?model=shopify.config&id={self.id}'
                    f'&field=sku_barcode_report_file&download=true&filename={fname}'),
            'target': 'self',
        }

    def action_export_sku_barcodes(self):
        """Genera un CSV con SKU actual, SKU sugerido y código de barras de todos los productos."""
        self.ensure_one()
        lines = self.simulation_line_ids
        if not lines:
            raise UserError("No hay datos simulados para exportar. Ejecuta primero 'Recargar / Simular'.")

        output = io.StringIO()
        writer = csv.writer(output, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)

        writer.writerow([
            'PRODUCTO',
            'VARIANTE',
            'SKU ACTUAL (SHOPIFY)',
            'SKU SUGERIDO',
            'CÓDIGO DE BARRAS',
            'CATEGORÍA',
            'ORIGEN',
            'ESTADO',
        ])

        state_labels = {
            'draft': 'Sin SKU',
            'has_sku': 'Tiene SKU',
            'synced': 'Sincronizado',
            'error': 'Error',
        }

        for line in lines:
            writer.writerow([
                line.product_name or '',
                line.variant_name or '',
                line.old_sku or '',
                line.suggested_sku or '',
                line.barcode or '',
                line.sku_category_code or '',
                line.sku_source or '',
                state_labels.get(line.sync_state, line.sync_state or ''),
            ])

        csv_content = output.getvalue()
        output.close()

        unique_name = f"SKU_Barcodes_{self.name.replace(' ', '_')}_{fields.Date.today()}.csv"
        self.write({
            'sku_barcode_report_file': base64.b64encode(csv_content.encode('utf-8-sig')),
            'sku_barcode_report_filename': unique_name,
        })

        return {
            'type': 'ir.actions.act_url',
            'url': (
                f'/web/content/?model=shopify.config'
                f'&id={self.id}'
                f'&filename_field=sku_barcode_report_filename'
                f'&field=sku_barcode_report_file'
                f'&download=true'
                f'&filename={unique_name}'
            ),
            'target': 'self',
        }

    def action_export_duplicates(self):
        """Genera un archivo CSV con los duplicados detectados para revisión del cliente."""
        self.ensure_one()
        if not self.duplicate_line_ids:
            raise UserError("No hay duplicados detectados para exportar.")
            
        output = io.StringIO()
        writer = csv.writer(output, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
        
        # Encabezados
        writer.writerow([
            'PRODUCTO', 
            'VARIANTE', 
            'SKU ACTUAL (CONFLICTO)', 
            'CHOCA CON...', 
            'SKU SUGERIDO (PROPUESTA)',
            'CATEGORIA'
        ])
        
        for line in self.duplicate_line_ids:
            writer.writerow([
                line.product_name,
                line.variant_name,
                line.old_sku,
                line.duplicate_with,
                line.suggested_sku,
                line.sku_category_code
            ])
            
        csv_content = output.getvalue()
        output.close()
        
        # Guardar en el campo binario
        unique_name = f"Reporte_Duplicados_{self.name.replace(' ', '_')}_{fields.Date.today()}.csv"
        self.write({
            'duplicate_report_file': base64.b64encode(csv_content.encode('utf-8-sig')),
            'duplicate_report_filename': unique_name
        })
        
        # Retornar acción de descarga
        return {
            'type': 'ir.actions.act_url',
            'url': f'/web/content/?model=shopify.config&id={self.id}&filename_field=duplicate_report_filename&field=duplicate_report_file&download=true&filename={unique_name}',
            'target': 'self',
        }
    
    # Filtro visual para la lista
    filter_category = fields.Selection([
        ('all', 'Todas las categorías'),
        ('POL', 'POL - Polos'),
        ('TSH', 'TSH - Playeras'),
        ('HAT', 'HAT - Gorras'),
        ('HATV', 'HATV - Viseras'),
        ('JCK', 'JCK - Chamarras'),
        ('HOD', 'HOD - Hoodies'),
        ('BAG', 'BAG - Bolsas'),
        ('GLV', 'GLV - Guantes'),
        ('ACC', 'ACC - Accesorios'),
        ('BM', 'BM - Ball Markers'),
        ('COV', 'COV - Covers'),
        ('TWL', 'TWL - Toallas'),
        ('TNK', 'TNK - Tank Tops'),
        ('VST', 'VST - Chalecos'),
    ], string='Filtrar por Categoría', default='all')

    # Campo de búsqueda para filtrar dentro de las listas
    search_term = fields.Char(string='Buscar producto...', store=False)

    # Campos computados para las pestañas (Vistas filtradas)
    pending_line_ids = fields.Many2many('shopify.sku.simulation.line', compute='_compute_filtered_lines', inverse='_inverse_lines', readonly=False, string='Pendientes')
    synced_line_ids = fields.Many2many('shopify.sku.simulation.line', compute='_compute_filtered_lines', inverse='_inverse_lines', readonly=False, string='Sincronizados')
    duplicate_line_ids = fields.Many2many('shopify.sku.simulation.line', compute='_compute_filtered_lines', inverse='_inverse_lines', readonly=False, string='Duplicados en Shopify')
    all_line_ids = fields.Many2many('shopify.sku.simulation.line', compute='_compute_filtered_lines', inverse='_inverse_lines', readonly=False, string='Todo (Filtrado)')

    def _inverse_lines(self):
        """Método dummy — los cambios reales se guardan directo en simulation_line_ids."""
        pass

    @api.depends('simulation_line_ids', 'simulation_line_ids.sync_state', 'simulation_line_ids.old_sku', 'filter_category', 'search_term')
    def _compute_filtered_lines(self):
        for record in self:
            all_lines = record.simulation_line_ids

            # --- Detectar duplicados en memoria (sin queries a BD) ---
            sku_map = {}
            for line in all_lines:
                if line.old_sku:
                    sku_map.setdefault(line.old_sku, [])
                    sku_map[line.old_sku].append(line.id)
            duplicate_ids = set(
                lid for ids in sku_map.values() if len(ids) > 1 for lid in ids
            )

            # Aplicar filtro de categoría
            filtered = all_lines
            if record.filter_category and record.filter_category != 'all':
                filtered = all_lines.filtered(lambda l: l.sku_category_code == record.filter_category)

            # Aplicar búsqueda de texto (nombre, variante, SKU actual, SKU sugerido)
            term = (record.search_term or '').strip().lower()
            if term:
                filtered = filtered.filtered(lambda l: (
                    term in (l.product_name or '').lower() or
                    term in (l.variant_name or '').lower() or
                    term in (l.old_sku or '').lower() or
                    term in (l.suggested_sku or '').lower()
                ))

            # PENDIENTES = solo los que NO tienen ningún SKU en Shopify
            record.pending_line_ids = filtered.filtered(lambda l: l.sync_state == 'draft')
            # SINCRONIZADOS = los que ya tienen SKU (correcto o diferente al sugerido)
            record.synced_line_ids = filtered.filtered(lambda l: l.sync_state in ('synced', 'has_sku'))
            # TODO (VISTA COMPLETA) = Todas las líneas que pasen el filtro de búsqueda/categoría
            record.all_line_ids = filtered
            
            # Duplicados: aplica búsqueda pero no filtro de categoría
            dup_lines = all_lines.filtered(lambda l: l.id in duplicate_ids)
            if term:
                dup_lines = dup_lines.filtered(lambda l: (
                    term in (l.product_name or '').lower() or
                    term in (l.variant_name or '').lower() or
                    term in (l.old_sku or '').lower() or
                    term in (l.suggested_sku or '').lower()
                ))
            record.duplicate_line_ids = dup_lines

    # Contadores y Estado
    is_syncing = fields.Boolean(string="Sincronizando...", default=False)
    sync_progress = fields.Float(string="Progreso Sync", default=0.0)

    # Dashboard de Progreso
    total_variant_count = fields.Integer(string="Total Variantes", compute="_compute_import_stats")
    imported_variant_count = fields.Integer(string="En Odoo", compute="_compute_import_stats")
    pending_variant_count = fields.Integer(string="Pendientes", compute="_compute_import_stats")
    selected_variant_count = fields.Integer(string="Seleccionados", compute="_compute_import_stats")

    @api.depends('simulation_line_ids.exists_in_odoo', 'simulation_line_ids.is_selected')
    def _compute_import_stats(self):
        for record in self:
            lines = record.simulation_line_ids
            record.total_variant_count = len(lines)
            record.imported_variant_count = len(lines.filtered(lambda l: l.exists_in_odoo))
            record.pending_variant_count = record.total_variant_count - record.imported_variant_count
            record.selected_variant_count = len(lines.filtered(lambda l: l.is_selected))
    total_count = fields.Integer(string='Total', compute='_compute_counts', store=False)
    pending_count = fields.Integer(string='Pendientes', compute='_compute_counts', store=False)
    synced_count = fields.Integer(string='Sincronizados', compute='_compute_counts', store=False)
    duplicate_count = fields.Integer(string='Duplicados en Web', compute='_compute_counts', store=False)
    products_total = fields.Integer(string='Productos en Odoo', compute='_compute_product_counts', store=False)
    products_with_image = fields.Integer(string='Productos con Imagen', compute='_compute_product_counts', store=False)
    products_without_image = fields.Integer(string='Sin Imagen', compute='_compute_product_counts', store=False)

    def _compute_product_counts(self):
        """Cuenta productos importados desde Shopify y cuántos tienen imagen."""
        for record in self:
            domain = [('shopify_id', '!=', False)]
            all_tmpls = self.env['product.template'].search(domain)
            with_img  = all_tmpls.filtered(lambda t: t.image_1920)
            record.products_total        = len(all_tmpls)
            record.products_with_image   = len(with_img)
            record.products_without_image = len(all_tmpls) - len(with_img)

    def _compute_counts(self):
        for record in self:
            record.total_count = len(record.simulation_line_ids)
            # Pendientes = sin SKU
            record.pending_count = len(record.simulation_line_ids.filtered(lambda l: l.sync_state == 'draft'))
            # Sincronizados = tienen algún SKU
            record.synced_count = len(record.simulation_line_ids.filtered(lambda l: l.sync_state in ('synced', 'has_sku')))
            record.duplicate_count = len(record.simulation_line_ids.filtered(lambda l: l.is_shopify_duplicate))

    # Campos para exportación de reportes
    duplicate_report_file = fields.Binary(string='Reporte de Duplicados')
    duplicate_report_filename = fields.Char(string='Nombre de Archivo Reporte')

    select_category = fields.Selection([
        ('POL', 'POL - Polos'),
        ('TSH', 'TSH - Playeras'),
        ('HAT', 'HAT - Gorras'),
        ('HATV', 'HATV - Viseras'),
        ('JCK', 'JCK - Chamarras'),
        ('HOD', 'HOD - Hoodies'),
        ('BAG', 'BAG - Bolsas'),
        ('GLV', 'GLV - Guantes'),
        ('ACC', 'ACC - Accesorios'),
        ('BM', 'BM - Ball Markers'),
        ('COV', 'COV - Covers'),
        ('TWL', 'TWL - Toallas'),
        ('TNK', 'TNK - Tank Tops'),
        ('VST', 'VST - Chalecos'),
    ], string='Categoría a Marcar')

    def action_get_access_token(self):
        """Intercambia el Client ID y el Client Secret por un Token de acceso (OAuth Client Credentials)."""
        self.ensure_one()
        if not self.api_key or not self.api_secret or not self.shop_url:
            raise UserError(_("Primero ingresa el Client ID, Client Secret y la URL de la tienda."))
        
        # Normalización agresiva del dominio
        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        else:
            shop_domain = shop_domain.split('/')[0]
            
        url = f"https://{shop_domain}/admin/oauth/access_token"
        
        # Datos según documentación: https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets
        payload = {
            "client_id": self.api_key.strip(),
            "client_secret": self.api_secret.strip(),
            "grant_type": "client_credentials"
        }
        
        try:
            _logger.info("Intentando intercambio OAuth Client Credentials en: %s", url)
            response = requests.post(url, json=payload, timeout=15)
            
            if response.status_code == 200:
                data = response.json()
                token = data.get('access_token')
                expires_in = data.get('expires_in', 86399) # 24h por defecto
                if token:
                    expiry = fields.Datetime.now() + datetime.timedelta(seconds=expires_in - 60)
                    self.write({
                        'access_token': token,
                        'access_token_expiry': expiry
                    })
                    return token
                else:
                    raise UserError(_("Shopify respondió exitosamente pero no envió un token en la respuesta."))
            else:
                _logger.error("Error en intercambio de token: %s", response.text)
                raise UserError(_("Error de Shopify (Status %s): %s") % (response.status_code, response.text))
        except Exception as e:
            raise UserError(_("Error de red al intentar obtener el token: %s") % str(e))

    def action_test_connection(self):
        """Prueba la conexión con Shopify (usando el nuevo sistema de tokens)."""
        self.ensure_one()
        try:
            token = self._ensure_access_token()
            shop_domain = self.shop_url.strip().lower()
            if '://' in shop_domain:
                shop_domain = shop_domain.split('://')[1].split('/')[0]
            else:
                shop_domain = shop_domain.split('/')[0]
            
            url = f"https://{shop_domain}/admin/api/2024-01/shop.json"
            headers = {"X-Shopify-Access-Token": token}
            
            response = requests.get(url, headers=headers, timeout=10)
            if response.status_code == 200:
                data = response.json()
                shop_name = data.get('shop', {}).get('name', 'Tienda Shopify')
                return {
                    'type': 'ir.actions.client',
                    'tag': 'display_notification',
                    'params': {
                        'title': _('Conexión Exitosa'),
                        'message': _('Odoo se comunicó correctamente con %s.') % shop_name,
                        'type': 'success',
                        'sticky': False,
                    }
                }
            else:
                raise UserError(_("Error de conexión (%s): %s") % (response.status_code, response.text))
        except Exception as e:
            raise UserError(_("Error de conexión: %s") % str(e))

    def _notify_shopify_fulfillment(self, sale_order, tracking_number=None, carrier_name=None, notify_customer=True):
        """
        Marca un pedido de Shopify como 'Fulfilled'.

        Flujo con doble fallback:
          1. Intenta endpoint legacy /orders/{id}/fulfillments.json (scope: write_orders)
          2. Si recibe 403/406, intenta nuevo endpoint /fulfillments.json vía fulfillment_orders
             (scopes: write_fulfillments + write_assigned_fulfillment_orders)
          3. Si aún falta permiso, muestra mensaje claro con instrucciones

        Args:
            sale_order: record de sale.order con shopify_order_id
            tracking_number: número de guía DHL (opcional)
            carrier_name: nombre del paquetero (default 'DHL')
            notify_customer: enviar correo de Shopify al cliente (default True)
        """
        self.ensure_one()
        token = self._ensure_access_token()
        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        else:
            shop_domain = shop_domain.split('/')[0]

        shopify_order_id = sale_order.shopify_order_id
        headers = {
            'X-Shopify-Access-Token': token,
            'Content-Type': 'application/json',
        }
        api_version = '2024-01'

        # ── Intento 1: Endpoint Legacy ─────────────────────────────────────────
        ful_url = f"https://{shop_domain}/admin/api/{api_version}/orders/{shopify_order_id}/fulfillments.json"
        legacy_payload = {
            'fulfillment': {
                'notify_customer': notify_customer,
            }
        }
        if tracking_number:
            legacy_payload['fulfillment']['tracking_number'] = tracking_number
            # Mensajería Local → enviar como 'Other' a Shopify (no es paquetería formal)
            shopify_company = 'Other' if carrier_name == 'MensajeriaLocal' else (carrier_name or 'DHL')
            legacy_payload['fulfillment']['tracking_company'] = shopify_company
            legacy_payload['fulfillment']['tracking_url'] = (
                f"https://www.dhl.com/mx-es/home/rastreo.html?tracking-id={tracking_number}"
                if (carrier_name or '').upper() in ('DHL', '') else ''
            )

        _logger.info("[Fulfillment] Intento 1 (Legacy) para orden Shopify #%s", shopify_order_id)
        ful_resp = requests.post(ful_url, headers=headers, json=legacy_payload, timeout=20)

        if ful_resp.status_code in (200, 201):
            ful_data = ful_resp.json().get('fulfillment', {})
            fulfillment_id = str(ful_data.get('id', ''))
            _logger.info("[Fulfillment] ✅ Fulfillment legacy creado: id=%s", fulfillment_id)
            sale_order.write({
                'shopify_fulfillment_id': fulfillment_id,
                'shopify_fulfillment_status': 'fulfilled',
            })
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': '✅ Fulfillment enviado a Shopify',
                    'message': 'El pedido %s fue marcado como Enviado en Shopify (ID: %s).' % (sale_order.origin, fulfillment_id),
                    'type': 'success',
                    'sticky': False,
                }
            }

        # ── Intento 2: Nuevo endpoint via Fulfillment Orders API ──────────────
        # Se activa cuando legacy devuelve 403 (sin permiso) o 406 (pedido no compatible)
        _logger.warning(
            "[Fulfillment] Endpoint legacy devolvió HTTP %s. Intentando nuevo endpoint...",
            ful_resp.status_code
        )

        fo_url = f"https://{shop_domain}/admin/api/{api_version}/orders/{shopify_order_id}/fulfillment_orders.json"
        fo_resp = requests.get(fo_url, headers=headers, timeout=15)

        if fo_resp.status_code == 403:
            _logger.error("[Fulfillment] Token sin permisos de fulfillment.")
            sale_order.write({'shopify_fulfillment_status': 'error'})
            raise UserError(
                "⚠️  El token de Shopify no tiene los permisos para crear fulfillments.\n\n"
                "SCOPES FALTANTES EN LA APP DE SHOPIFY:\n"
                "  • Fulfillments → Leer y escribir\n"
                "  • Merchant-managed fulfillment orders → Leer y escribir\n\n"
                "CÓMO AGREGARLOS:\n"
                "1. Shopify Admin → Configuración → Apps y canales de ventas\n"
                "2. Selecciona la Custom App (ej. 'Odoo Integración')\n"
                "3. Haz clic en 'Configurar permisos de la Admin API'\n"
                "4. Activa los permisos de Fulfillments\n"
                "5. Guarda e instala la app → copia el nuevo token en Odoo"
            )

        if fo_resp.status_code != 200:
            sale_order.write({'shopify_fulfillment_status': 'error'})
            raise UserError(
                "Error al consultar fulfillment_orders en Shopify.\n"
                "Código: %s\nDetalle: %s" % (fo_resp.status_code, fo_resp.text)
            )

        fo_data = fo_resp.json().get('fulfillment_orders', [])
        open_fos = [fo for fo in fo_data if fo.get('status') in ('open', 'in_progress')]

        if not open_fos:
            _logger.info("[Fulfillment] No hay FO abiertos para #%s — ya cumplido en Shopify", shopify_order_id)
            sale_order.write({'shopify_fulfillment_status': 'fulfilled'})
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': 'Shopify — Ya cumplido',
                    'message': 'El pedido %s ya está marcado como Enviado en Shopify.' % sale_order.origin,
                    'type': 'warning',
                    'sticky': False,
                }
            }

        # ── Agrupar FOs por ubicación asignada ────────────────────────────────
        # Shopify requiere que todas las FOs en un fulfillment estén en la misma
        # ubicación. Si hay items en varias ubicaciones (ej: sustitución de
        # producto, o traspaso parcial), se crea un fulfillment por cada ubicación.
        from collections import defaultdict
        fos_by_location = defaultdict(list)
        for fo in open_fos:
            loc_id = fo.get('assigned_location_id') or 'unknown'
            fos_by_location[loc_id].append(fo)

        if len(fos_by_location) > 1:
            _logger.info(
                "[Fulfillment] Pedido #%s tiene FOs en %s ubicaciones distintas (%s). "
                "Se creará un fulfillment por cada ubicación.",
                shopify_order_id, len(fos_by_location), list(fos_by_location.keys())
            )

        new_url = f"https://{shop_domain}/admin/api/{api_version}/fulfillments.json"
        fulfillment_ids = []
        errors = []

        for loc_id, loc_fos in fos_by_location.items():
            new_payload = {
                'fulfillment': {
                    'line_items_by_fulfillment_order': [
                        {'fulfillment_order_id': fo['id']} for fo in loc_fos
                    ],
                    'notify_customer': notify_customer and (len(fulfillment_ids) == 0),
                }
            }
            if tracking_number:
                # Mensajería Local → enviar como 'Other' a Shopify
                shopify_company = 'Other' if carrier_name == 'MensajeriaLocal' else (carrier_name or 'DHL')
                new_payload['fulfillment']['tracking_info'] = {
                    'number': tracking_number,
                    'company': shopify_company,
                }

            _logger.info(
                "[Fulfillment] Enviando fulfillment para orden #%s — ubicación %s (%s FOs)",
                shopify_order_id, loc_id, len(loc_fos)
            )
            new_resp = requests.post(new_url, headers=headers, json=new_payload, timeout=20)

            if new_resp.status_code in (200, 201):
                new_data = new_resp.json().get('fulfillment', {})
                ful_id = str(new_data.get('id', ''))
                fulfillment_ids.append(ful_id)
                _logger.info("[Fulfillment] ✅ Fulfillment creado: id=%s (ubicación %s)", ful_id, loc_id)
            else:
                err_msg = new_resp.text
                _logger.error(
                    "[Fulfillment] ❌ Error en ubicación %s: %s - %s",
                    loc_id, new_resp.status_code, err_msg
                )
                errors.append({'location': loc_id, 'status': new_resp.status_code, 'detail': err_msg})
                if new_resp.status_code == 403:
                    sale_order.write({'shopify_fulfillment_status': 'error'})
                    raise UserError(
                        "⚠️  El token de Shopify no tiene los permisos para crear fulfillments.\n\n"
                        "SCOPES FALTANTES:\n"
                        "  • Fulfillments → Leer y escribir\n"
                        "  • Merchant-managed fulfillment orders → Leer y escribir\n\n"
                        "Agrégalos en Shopify Admin → Configuración → Apps → tu Custom App → Permisos API."
                    )

        # ── Resultado final ───────────────────────────────────────────────────
        if fulfillment_ids:
            combined_id = ','.join(fulfillment_ids)
            status = 'fulfilled' if not errors else 'partial'
            sale_order.write({
                'shopify_fulfillment_id': combined_id,
                'shopify_fulfillment_status': status,
            })
            msg = 'El pedido %s fue marcado como Enviado en Shopify (IDs: %s).' % (sale_order.origin, combined_id)
            if errors:
                msg += '\n⚠️ %s de %s ubicaciones fallaron. Revisa los logs.' % (len(errors), len(fos_by_location))
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': '✅ Fulfillment enviado a Shopify',
                    'message': msg,
                    'type': 'success' if not errors else 'warning',
                    'sticky': bool(errors),
                }
            }
        else:
            sale_order.write({'shopify_fulfillment_status': 'error'})
            detail = errors[0]['detail'] if errors else 'Sin detalle'
            raise UserError(
                "Error al crear el fulfillment en Shopify.\n"
                "Ninguna ubicación se pudo completar.\nDetalle: %s" % detail
            )

    def _notify_shopify_ready_for_pickup(self, sale_order):
        """
        Marca las líneas del pedido de Shopify como 'Ready for Pickup' vía GraphQL.
        """
        self.ensure_one()
        token = self._ensure_access_token()
        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        else:
            shop_domain = shop_domain.split('/')[0]

        shopify_order_id = sale_order.shopify_order_id
        headers = {
            'X-Shopify-Access-Token': token,
            'Content-Type': 'application/json',
        }
        graphql_url = f"https://{shop_domain}/admin/api/2024-01/graphql.json"

        # 1. Buscar fulfillment orders para este pedido
        # El ID de orden de Shopify en GraphQL debe ser en formato "gid://shopify/Order/XXXX"
        graphql_order_id = f"gid://shopify/Order/{shopify_order_id}"
        
        query = """
        query getFulfillmentOrders($orderId: ID!) {
            order(id: $orderId) {
                fulfillmentOrders(first: 10) {
                    edges {
                        node {
                            id
                            status
                            deliveryMethod {
                                methodType
                            }
                            lineItems(first: 50) {
                                edges {
                                    node {
                                        id
                                        totalQuantity
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        """
        
        _logger.info("[BOPIS] Buscando fulfillment orders en Shopify para orden #%s", shopify_order_id)
        try:
            resp = requests.post(graphql_url, headers=headers, json={'query': query, 'variables': {'orderId': graphql_order_id}}, timeout=20)
        except Exception as e:
            _logger.error("[BOPIS] Error de conexión al consultar fulfillment orders: %s", str(e))
            return False
        
        if resp.status_code != 200:
            _logger.error("[BOPIS] Error al consultar fulfillment orders: HTTP %s - %s", resp.status_code, resp.text)
            return False
            
        data = resp.json() or {}
        if 'errors' in data:
            _logger.error("[BOPIS] GraphQL devolvió errores al consultar: %s", data.get('errors'))
            return False
            
        order_data = data.get('data', {}).get('order') or {}
        fo_edges = order_data.get('fulfillmentOrders', {}).get('edges') or []
        
        # 2. Encontrar las órdenes de fulfillment abiertas de tipo retiro
        line_items_by_fo = []
        for edge in fo_edges:
            node = edge.get('node', {})
            # Solo procesamos fulfillment orders en estado 'OPEN' o 'IN_PROGRESS'
            if node.get('status') not in ('OPEN', 'IN_PROGRESS'):
                continue
            
            # Asegurarnos de preparar solo si el método de entrega es PICK_UP o no está especificado
            delivery_method = node.get('deliveryMethod') or {}
            method_type = (delivery_method.get('methodType') or '').upper()
            if method_type and method_type != 'PICK_UP':
                continue
                
            fo_id = node.get('id')
            line_items_by_fo.append({
                'fulfillmentOrderId': fo_id
            })
                
        if not line_items_by_fo:
            _logger.info("[BOPIS] No se encontraron órdenes de fulfillment pendientes para retiro en Shopify.")
            return False
            
        # 3. Ejecutar la mutación para marcar como listos para retiro
        mutation = """
        mutation fulfillmentOrderLineItemsPreparedForPickup($input: FulfillmentOrderLineItemsPreparedForPickupInput!) {
            fulfillmentOrderLineItemsPreparedForPickup(input: $input) {
                userErrors {
                    field
                    message
                }
            }
        }
        """
        
        variables = {
            'input': {
                'lineItemsByFulfillmentOrder': line_items_by_fo
            }
        }
        
        _logger.info("[BOPIS] Ejecutando mutación 'fulfillmentOrderLineItemsPreparedForPickup' para %s elementos", len(line_items_by_fo))
        try:
            mut_resp = requests.post(graphql_url, headers=headers, json={'query': mutation, 'variables': variables}, timeout=20)
        except Exception as e:
            _logger.error("[BOPIS] Error de conexión en la mutación: %s", str(e))
            return False
        
        if mut_resp.status_code != 200:
            _logger.error("[BOPIS] Error en la mutación: HTTP %s - %s", mut_resp.status_code, mut_resp.text)
            return False
            
        mut_data = mut_resp.json() or {}
        if 'errors' in mut_data:
            _logger.error("[BOPIS] GraphQL devolvió errores en la mutación: %s", mut_data.get('errors'))
            return False
            
        result = mut_data.get('data', {}).get('fulfillmentOrderLineItemsPreparedForPickup') or {}
        user_errors = result.get('userErrors') or []
        if user_errors:
            _logger.warning("[BOPIS] Errores de usuario de Shopify al marcar listo para retiro: %s", user_errors)
            # FALLBACK: Si no se puede marcar como "Listo para Retiro" (ej: porque Local Pickup no está activado en Shopify),
            # intentamos marcarlo como Cumplido/Enviado estándar para no romper la sincronización.
            _logger.info("[BOPIS] Intentando fallback a fulfillment estándar para orden #%s...", shopify_order_id)
            try:
                return self._notify_shopify_fulfillment(sale_order, notify_customer=True)
            except Exception as e:
                _logger.error("[BOPIS] Fallback de fulfillment estándar también falló: %s", str(e))
                return False
            
        _logger.info("[BOPIS] ✅ Marcado con éxito 'Listo para retiro' en Shopify para orden #%s", shopify_order_id)
        
        # Guardar estado en Odoo (pending representa a la espera del retiro físico)
        sale_order.write({
            'shopify_fulfillment_status': 'pending',
        })
        return True

    def _notify_shopify_fulfillment_order_move(self, sale_order):
        """
        Mueve la Fulfillment Order de Shopify a la ubicación del almacén de entrega en Odoo
        para reflejar el estado "Transferido" en Shopify.
        """
        self.ensure_one()
        token = self._ensure_access_token()
        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        else:
            shop_domain = shop_domain.split('/')[0]

        shopify_order_id = sale_order.shopify_order_id
        headers = {
            'X-Shopify-Access-Token': token,
            'Content-Type': 'application/json',
        }
        graphql_url = f"https://{shop_domain}/admin/api/2024-01/graphql.json"

        # 1. Resolver la ubicación destino en Shopify
        dest_wh = sale_order.warehouse_id
        if not dest_wh:
            _logger.warning("[BOPIS Move] No hay almacén destino en el pedido %s.", sale_order.name)
            return False

        map_loc = self.env['shopify.location.mapping'].search([('odoo_warehouse_id', '=', dest_wh.id)], limit=1)
        if not map_loc or not map_loc.shopify_location_id:
            _logger.warning("[BOPIS Move] No hay mapeo de sucursal de Shopify para el almacén %s.", dest_wh.name)
            return False

        shopify_location_id = map_loc.shopify_location_id
        graphql_location_id = f"gid://shopify/Location/{shopify_location_id}"

        # 2. Buscar las open fulfillment orders
        graphql_order_id = f"gid://shopify/Order/{shopify_order_id}"
        
        query = """
        query getFulfillmentOrders($orderId: ID!) {
            order(id: $orderId) {
                fulfillmentOrders(first: 10) {
                    edges {
                        node {
                            id
                            status
                            assignedLocation {
                                location {
                                    id
                                }
                            }
                        }
                    }
                }
            }
        }
        """
        
        _logger.info("[BOPIS Move] Buscando FO en Shopify para orden #%s", shopify_order_id)
        try:
            resp = requests.post(graphql_url, headers=headers, json={'query': query, 'variables': {'orderId': graphql_order_id}}, timeout=20)
        except Exception as e:
            _logger.error("[BOPIS Move] Error de conexión: %s", str(e))
            return False
            
        if resp.status_code != 200:
            _logger.error("[BOPIS Move] Error HTTP %s: %s", resp.status_code, resp.text)
            return False
            
        data = resp.json() or {}
        order_data = data.get('data', {}).get('order') or {}
        fo_edges = order_data.get('fulfillmentOrders', {}).get('edges') or []
        
        fo_to_move = []
        for edge in fo_edges:
            node = edge.get('node', {})
            if node.get('status') in ('OPEN', 'SCHEDULED', 'ON_HOLD'):
                curr_loc = node.get('assignedLocation', {}).get('location', {}).get('id') or ''
                if curr_loc != graphql_location_id:
                    fo_to_move.append(node.get('id'))

        if not fo_to_move:
            _logger.info("[BOPIS Move] Las FO ya están en la ubicación destino o no hay pendientes.")
            return True

        # 3. Mover las FO a la ubicación destino de la oficina
        mutation = """
        mutation fulfillmentOrderMove($id: ID!, $newLocationId: ID!) {
            fulfillmentOrderMove(id: $id, newLocationId: $newLocationId) {
                movedFulfillmentOrder {
                    id
                }
                userErrors {
                    field
                    message
                }
            }
        }
        """
        
        success = True
        for fo_id in fo_to_move:
            variables = {
                'id': fo_id,
                'newLocationId': graphql_location_id
            }
            _logger.info("[BOPIS Move] Moviendo FO %s a location %s", fo_id, graphql_location_id)
            try:
                mut_resp = requests.post(graphql_url, headers=headers, json={'query': mutation, 'variables': variables}, timeout=20)
                if mut_resp.status_code == 200:
                    mut_data = mut_resp.json() or {}
                    result = mut_data.get('data', {}).get('fulfillmentOrderMove') or {}
                    user_errors = result.get('userErrors') or []
                    if user_errors:
                        _logger.warning("[BOPIS Move] UserErrors: %s", user_errors)
                        success = False
                    else:
                        _logger.info("[BOPIS Move] FO %s movida con éxito.", fo_id)
                else:
                    _logger.error("[BOPIS Move] HTTP %s: %s", mut_resp.status_code, mut_resp.text)
                    success = False
            except Exception as e_mut:
                _logger.error("[BOPIS Move] Error en mutación para %s: %s", fo_id, str(e_mut))
                success = False

        return success

    def action_fetch_locations(self):
        """Obtiene todas las ubicaciones de Shopify y las mete en la tabla de mapeo."""
        self.ensure_one()
        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        else:
            shop_domain = shop_domain.split('/')[0]
        token = self._ensure_access_token()
        
        url = f"https://{shop_domain}/admin/api/2024-01/locations.json"
        headers = {"X-Shopify-Access-Token": token}
        
        try:
            response = requests.get(url, headers=headers, timeout=15)
            if response.status_code == 200:
                locations = response.json().get('locations', [])
                if not locations:
                    raise UserError(_("No se encontraron ubicaciones en Shopify."))
                
                # Actualizar o crear registros en la tabla de mapeo
                for loc in locations:
                    loc_id = str(loc['id'])
                    existing = self.location_mapping_ids.filtered(lambda m: m.shopify_location_id == loc_id)
                    if existing:
                        existing.write({'shopify_location_name': loc['name']})
                    else:
                        self.env['shopify.location.mapping'].create({
                            'config_id': self.id,
                            'shopify_location_id': loc_id,
                            'shopify_location_name': loc['name'],
                        })
                    
                    # Actualizar también el campo principal en el config si coincide con el ID principal
                    if self.shopify_location_id == loc_id:
                        self.write({'shopify_location_name': loc['name']})
                
                return {
                    'type': 'ir.actions.client',
                    'tag': 'display_notification',
                    'params': {
                        'title': _('Sucursales Actualizadas'),
                        'message': _('Se han traído %s ubicaciones de Shopify. Vincúlalas con tus almacenes de Odoo.') % len(locations),
                        'type': 'success',
                        'sticky': False,
                    }
                }
            else:
                raise UserError(_("Error al obtener ubicaciones: %s") % response.text)
        except Exception as e:
            raise UserError(_("Error de red: %s") % str(e))

    def action_sync_products(self):
        """
        Importa (o actualiza) todos los productos de Shopify a Odoo.
        Estructura correcta: 1 product.template por producto Shopify
        con sus variantes creadas via product.attribute (Talla, Color, etc.).
        """
        self.ensure_one()
        token = self._ensure_access_token()
        if not token:
            raise UserError(_("No hay token de acceso válido. Genera uno en la configuración."))

        try:
            self.sku_simulation_log = "INICIANDO Sincronización de Catálogo...\nDescargando productos desde Shopify (Esto puede tomar tiempo)...\n"
            self.env.cr.commit()

            all_products = self._get_all_shopify_products()
            total_prods = len(all_products)
            
            self.sku_simulation_log += f"✅ Se descargaron {total_prods} productos. Comenzando a importarlos a Odoo...\n"
            self.env.cr.commit()

            count_created = 0
            count_updated = 0
            count_errors  = 0

            for i, item in enumerate(all_products):
                try:
                    created = self._import_shopify_product(item)
                    if created:
                        count_created += 1
                    else:
                        count_updated += 1
                except Exception as e:
                    count_errors += 1
                    _logger.error(
                        "[Sync Products] Error importando producto '%s': %s",
                        item.get('title', '?'), str(e)
                    )
                    self.env.cr.rollback()

                # Hacer commit cada 10 productos para que el Log en pantalla se actualice "en vivo"
                if (i + 1) % 10 == 0 or (i + 1) == total_prods:
                    self.sku_simulation_log += f"⏳ Procesando: {i + 1} de {total_prods} productos listos...\n"
                    self.env.cr.commit()

            msg = _(
                "Sincronización completa.\n"
                "Creados: %s | Actualizados: %s | Errores: %s"
            ) % (count_created, count_updated, count_errors)
            
            self.sku_simulation_log += f"✅ FIN DEL PROCESO.\n{msg}\n"
            self.env.cr.commit()
            
            _logger.info("[Sync Products] %s", msg)

            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': _('Productos sincronizados'),
                    'message': msg,
                    'type': 'success' if not count_errors else 'warning',
                    'sticky': count_errors > 0,
                }
            }
        except Exception as e:
            raise UserError(_("Error durante la sincronización: %s") % str(e))

    def _import_shopify_product(self, item):
        """
        Crea o actualiza un product.template desde un dict de producto Shopify.
        Retorna True si fue creado, False si fue actualizado.
        
        Estructura Shopify:
          item.options  = [{name: 'Talla', values: ['S','M','L']}, ...]
          item.variants = [{id, sku, option1, option2, price, inventory_item_id}, ...]
        """
        shopify_id   = str(item.get('id'))
        variants     = item.get('variants', [])
        options      = item.get('options', [])
        first_var    = variants[0] if variants else {}

        # Producto simple: solo 1 variante con título "Default Title"
        is_simple = (
            len(variants) == 1 and
            first_var.get('title', '').strip().lower() in ('default title', '')
        )

        # Valores base del template
        base_vals = {
            'name':              item.get('title') or 'Sin nombre',
            'description_sale':  item.get('body_html') or '',
            'shopify_id':        shopify_id,
            'shopify_published': item.get('status') == 'active',
            'shopify_config_id': self.id,
            'shopify_synced_at': fields.Datetime.now(),
            'shopify_sync_status': 'synced',
            'type':              'product',   # Almacenable
            'list_price':        float(first_var.get('price') or 0.0),
        }

        # Buscar template existente por shopify_id
        template = self.env['product.template'].search(
            [('shopify_id', '=', shopify_id)], limit=1
        )

        was_created = False
        if not template:
            # Crear nuevo — sin attribute_line_ids todavía
            template = self.env['product.template'].with_context(
                no_recompute=True
            ).create(base_vals)
            was_created = True
        else:
            template.write(base_vals)

        # ── Configurar atributos y variantes ─────────────────────
        if is_simple:
            # Producto sin variantes reales
            variant = template.product_variant_ids[:1]
            if variant:
                variant.write({
                    'shopify_variant_id':        str(first_var.get('id') or ''),
                    'shopify_inventory_item_id': str(first_var.get('inventory_item_id') or ''),
                    'default_code':              first_var.get('sku') or '',
                })
        else:
            self._setup_product_attributes(template, options, variants)

        return was_created

    def _setup_product_attributes(self, template, options, shopify_variants):
        """
        Configura los atributos (Talla, Color, etc.) en el template
        y asocia cada product.product generado con su variante en Shopify.
        """
        # ── Paso 1: Crear/actualizar las attribute_line_ids del template ──
        for option in options:
            attr_name   = (option.get('name') or 'Atributo').strip()
            attr_values = [v for v in option.get('values', []) if v and v != 'Default Title']
            if not attr_values:
                continue

            # Buscar o crear el product.attribute
            attr = self.env['product.attribute'].search(
                [('name', '=ilike', attr_name)], limit=1
            )
            if not attr:
                attr = self.env['product.attribute'].create({
                    'name':             attr_name,
                    'create_variant':   'always',
                })

            # Buscar o crear los product.attribute.value
            val_ids = []
            for val_name in attr_values:
                val = self.env['product.attribute.value'].search([
                    ('name',         '=ilike', val_name),
                    ('attribute_id', '=',      attr.id),
                ], limit=1)
                if not val:
                    val = self.env['product.attribute.value'].create({
                        'name':         val_name,
                        'attribute_id': attr.id,
                    })
                val_ids.append(val.id)

            # Agregar o actualizar la línea de atributo en el template
            existing_line = template.attribute_line_ids.filtered(
                lambda l: l.attribute_id.id == attr.id
            )
            if existing_line:
                existing_line.write({'value_ids': [(6, 0, val_ids)]})
            else:
                template.write({
                    'attribute_line_ids': [(0, 0, {
                        'attribute_id': attr.id,
                        'value_ids':    [(6, 0, val_ids)],
                    })]
                })

        # ── Paso 2: Mapear cada variante Odoo ↔ Shopify por option values ──
        self._match_variants_to_shopify(template, shopify_variants, options)

    def _match_variants_to_shopify(self, template, shopify_variants, options):
        """
        Para cada variante en Shopify, busca la variante Odoo equivalente
        comparando los valores de opción (option1, option2, option3)
        y guarda shopify_variant_id, shopify_inventory_item_id y SKU.
        """
        for sv in shopify_variants:
            # Valores de opciones de esta variante en Shopify (normalizado)
            sv_vals = set()
            for i in range(1, 4):
                v = sv.get(f'option{i}')
                if v and v.strip().lower() not in ('', 'default title'):
                    sv_vals.add(v.strip().lower())

            matched = None

            # Intentar match por SKU primero (más preciso)
            sku = (sv.get('sku') or '').strip()
            if sku:
                matched = self.env['product.product'].search([
                    ('product_tmpl_id', '=', template.id),
                    ('default_code',    '=', sku),
                ], limit=1)

            # Si no hay match por SKU, comparar por combinación de atributos
            if not matched:
                for odoo_var in template.product_variant_ids:
                    odoo_vals = {
                        ptav.name.strip().lower()
                        for ptav in odoo_var.product_template_attribute_value_ids
                    }
                    if odoo_vals == sv_vals:
                        matched = odoo_var
                        break

            if matched:
                write_vals = {
                    'shopify_variant_id':        str(sv.get('id') or ''),
                    'shopify_inventory_item_id': str(sv.get('inventory_item_id') or ''),
                    'default_code':              sku or matched.default_code or '',
                }
                # Importar barcode de Shopify si Odoo no tiene uno ya
                shopify_barcode = (sv.get('barcode') or '').strip()
                if shopify_barcode and not matched.barcode:
                    write_vals['barcode'] = shopify_barcode
                matched.write(write_vals)
            else:
                _logger.warning(
                    "[Sync Products] No se pudo mapear variante Shopify %s "
                    "(SKU: %s, vals: %s) en template '%s'",
                    sv.get('id'), sku, sv_vals, template.name
                )

    def action_import_inventory_from_shopify(self):
        """
        Importa el stock desde Shopify → Odoo.
        Consulta inventory_levels de Shopify y actualiza stock.quant en Odoo.
        """
        self.ensure_one()
        token = self._ensure_access_token()
        if not token:
            raise UserError(_("No hay token de acceso válido."))

        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        headers = {"X-Shopify-Access-Token": token}

        # Buscar variantes que tienen inventory_item_id registrado
        variants = self.env['product.product'].search([
            ('shopify_inventory_item_id', 'not in', [False, '']),
        ])
        if not variants:
            raise UserError(_(
                "No hay productos vinculados con Shopify. "
                "Primero ejecuta 'Sincronizar Productos'."
            ))

        # Mapa: shopify_location_id → odoo stock.location
        location_map = {}
        for m in self.location_mapping_ids:
            if m.shopify_location_id and m.odoo_location_id:
                location_map[str(m.shopify_location_id)] = m.odoo_location_id

        # Ubicación por defecto: WH/Stock del almacén configurado
        default_wh = self.default_warehouse_id or self.env['stock.warehouse'].search([], limit=1)
        default_location = default_wh.lot_stock_id if default_wh else False
        if not default_location:
            raise UserError(_("No se encontró una ubicación de stock por defecto."))

        self.sku_simulation_log = "INICIANDO Importación de Stock de Shopify a Odoo...\n"
        self.env.cr.commit()

        count_updated = 0
        count_errors  = 0
        batch_size    = 50   # Límite de la API de Shopify

        variant_list = list(variants)
        for i in range(0, len(variant_list), batch_size):
            batch = variant_list[i:i + batch_size]
            inv_ids = ','.join(v.shopify_inventory_item_id for v in batch)

            url = (
                f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json"
                f"?inventory_item_ids={inv_ids}&limit=250"
            )
            try:
                resp = requests.get(url, headers=headers, timeout=20)
                if resp.status_code != 200:
                    _logger.error("[Import Inventory] API error: %s", resp.text[:200])
                    count_errors += 1
                    continue

                levels = resp.json().get('inventory_levels', [])
                for level in levels:
                    inv_item_id    = str(level.get('inventory_item_id') or '')
                    loc_shopify_id = str(level.get('location_id') or '')
                    available      = max(int(level.get('available') or 0), 0)

                    # Encontrar la variante Odoo
                    variant = next(
                        (v for v in batch if v.shopify_inventory_item_id == inv_item_id),
                        None
                    )
                    if not variant:
                        continue

                    # Determinar ubicación Odoo
                    odoo_loc = location_map.get(loc_shopify_id, default_location)

                    # Actualizar o crear el stock.quant
                    try:
                        existing = self.env['stock.quant'].sudo().search([
                            ('product_id',  '=', variant.id),
                            ('location_id', '=', odoo_loc.id),
                        ], limit=1)

                        current_qty = existing.quantity if existing else 0.0
                        diff = available - current_qty

                        if diff != 0:
                            self.env['stock.quant'].sudo()._update_available_quantity(
                                variant, odoo_loc, diff
                            )
                        count_updated += 1
                    except Exception as e:
                        _logger.error(
                            "[Import Inventory] Error actualizando %s: %s",
                            variant.default_code, str(e)
                        )
                        count_errors += 1

            except Exception as e:
                _logger.error("[Import Inventory] Error en batch %s: %s", i, str(e))
                count_errors += 1

            self.sku_simulation_log += f"⏳ Procesando Lote: {min(i + batch_size, len(variant_list))} de {len(variant_list)} variantes procesadas...\n"
            self.env.cr.commit()

        msg = _(
            "Inventario importado desde Shopify.\n"
            "Actualizados: %s variantes | Errores: %s"
        ) % (count_updated, count_errors)
        
        self.sku_simulation_log += f"✅ FIN DEL PROCESO DE STOCK.\n{msg}\n"
        self.env.cr.commit()
        
        _logger.info("[Import Inventory] %s", msg)

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Inventario Importado'),
                'message': msg,
                'type': 'success' if not count_errors else 'warning',
                'sticky': count_errors > 0,
            }
        }

    def action_download_product_images(self):
        """
        Activa el cron de descarga de imágenes por lotes.
        El cron procesa 15 productos por minuto sin límite de tiempo.
        """
        self.ensure_one()
        cron = self.env.ref(
            'hazard_shopify.ir_cron_shopify_download_images',
            raise_if_not_found=False
        )
        if not cron:
            raise UserError(_("No se encontró el cron de descarga de imágenes."))

        # Resetear el estado de error 'no_shopify_image' para forzar una nueva verificación en Shopify.
        # Esto permite que si subiste imágenes nuevas en Shopify, Odoo las intente descargar de nuevo.
        products_to_reset = self.env['product.template'].search([
            ('shopify_id', '!=', False),
            ('image_1920', '=', False),
            ('shopify_sync_error', '=', 'no_shopify_image')
        ])
        if products_to_reset:
            products_to_reset.write({'shopify_sync_error': False})
            self.env.cr.commit()

        # Contar pendientes (excluyendo fallidos confirmados sin imagen en Shopify)
        pending = self.env['product.template'].search_count([
            ('shopify_id', '!=', False),
            ('image_1920', '=', False),
            ('shopify_sync_error', 'not in', ['no_shopify_image']),
        ])

        if not pending:
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': _('Imágenes'),
                    'message': _('✅ Todos los productos ya tienen imagen.'),
                    'type': 'info',
                    'sticky': False,
                }
            }

        # Inicializar bitácora (iniciar una nueva operación con 🚀)
        self.write({
            'sku_simulation_log': f"📸 [Imágenes] Iniciando descarga de imágenes desde Shopify para {pending} productos pendientes..."
        })
        self.env.cr.commit()

        # Ejecutar el primer lote DE FORMA INSTANTÁNEA para dar feedback inmediato al usuario!
        try:
            self._cron_download_images_batch()
        except Exception as e_first:
            _logger.warning("[Images] Error ejecutando primer lote instantáneo: %s", str(e_first))

        # Activar el cron para que continúe procesando en el fondo minuto a minuto
        cron.sudo().write({'active': True})
        _logger.info("[Images] Cron de imágenes activado. Productos pendientes restantes en fondo.")

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Descarga de Imágenes Iniciada'),
                'message': _(
                    'Se procesó el primer lote inmediatamente. El cron continuará descargando el resto en segundo plano (15 por minuto).\n'
                    'Refresca esta página para ver el progreso en los contadores.'
                ),
                'type': 'success',
                'sticky': True,
            }
        }

    def _cron_download_images_batch(self):
        """
        Cron que descarga imágenes de Shopify en lotes de 15 productos.
        Se auto-desactiva cuando todos los productos tienen imagen.
        Llamado desde: ir_cron_shopify_download_images (cada 1 min).
        """
        BATCH_SIZE = 15

        configs = self.search([('access_token', '!=', False)], limit=1)
        if not configs:
            return

        config = configs[0]
        token = config._ensure_access_token()
        if not token:
            return

        shop_domain = config.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        headers = {"X-Shopify-Access-Token": token}

        # Productos sin imagen (excluir los que ya confirmamos que no tienen en Shopify)
        pending = self.env['product.template'].search([
            ('shopify_id', '!=', False),
            ('image_1920', '=', False),
            ('shopify_sync_error', 'not in', ['no_shopify_image']),
        ], limit=BATCH_SIZE)

        if not pending:
            # Todos tienen imagen → desactivar el cron de forma segura sin provocar bloqueos de fila
            cron = self.env.ref(
                'hazard_shopify.ir_cron_shopify_download_images',
                raise_if_not_found=False
            )
            if cron:
                import threading
                def deactivate_cron(dbname, cron_id):
                    import time
                    time.sleep(2)  # Esperar a que la transacción principal del cron libere el bloqueo
                    from odoo import registry
                    reg = registry(dbname)
                    with reg.cursor() as cr:
                        cr.execute("UPDATE ir_cron SET active = False WHERE id = %s", (cron_id,))
                        cr.commit()

                t = threading.Thread(target=deactivate_cron, args=(self.env.cr.dbname, cron.id))
                t.daemon = True
                t.start()
            
            msg = "✅ [Imágenes] Sincronización completada: Todas las variantes de producto ya cuentan con sus imágenes en Odoo. El proceso automático en segundo plano se ha apagado."
            config.write({'sku_simulation_log': msg})
            _logger.info("[Images] ✅ Todas las imágenes descargadas. Cron desactivado de forma segura en segundo plano.")
            return

        count_ok     = 0
        count_no_image = 0
        count_errors = 0
        for tmpl in pending:
            shopify_id = tmpl.shopify_id
            url = (
                f"https://{shop_domain}/admin/api/2024-01"
                f"/products/{shopify_id}/images.json?limit=1"
            )
            try:
                resp = requests.get(url, headers=headers, timeout=15)
                if resp.status_code != 200:
                    count_errors += 1
                    continue
                images = resp.json().get('images', [])
                if not images:
                    # Sin imagen en Shopify → marcar para no reintentar
                    tmpl.sudo().write({'shopify_sync_error': 'no_shopify_image'})
                    count_no_image += 1
                    self.env.cr.commit()
                    continue
                img_url = images[0].get('src')
                if not img_url:
                    count_no_image += 1
                    continue
                img_resp = requests.get(img_url, timeout=15)
                if img_resp.status_code == 200:
                    tmpl.sudo().write({
                        'image_1920': base64.b64encode(img_resp.content)
                    })
                    count_ok += 1
                    self.env.cr.commit()
            except Exception as e:
                _logger.warning(
                    "[Images] Error descargando imagen para '%s': %s",
                    tmpl.name, str(e)
                )
                count_errors += 1

        total_pending = self.env['product.template'].search_count([
            ('shopify_id', '!=', False),
            ('image_1920', '=', False),
            ('shopify_sync_error', 'not in', ['no_shopify_image']),
        ])
        
        # Escribir el avance a la bitácora en vivo de manera súper clara y amigable
        msg = f"🖼️ [Imágenes] Lote de 15 productos procesado con éxito:\n- {count_ok} nuevas imágenes descargadas.\n- {count_no_image} productos sin fotos en Shopify (omitidos).\n- {count_errors} fallos temporales de conexión.\n- Quedan {total_pending} productos pendientes por analizar."
        config.write({'sku_simulation_log': msg})
        
        _logger.info(
            "[Images] Lote completado: %s ok | %s sin imagen | %s errores | %s pendientes totales",
            count_ok, count_no_image, count_errors, total_pending
        )
        # Si no quedan pendientes, apagar el cron
        if total_pending == 0:
            cron = self.env.ref(
                'hazard_shopify.ir_cron_shopify_download_images',
                raise_if_not_found=False
            )
            if cron:
                cron.sudo().write({'active': False})
            
            timestamp_str = fields.Datetime.context_timestamp(config, fields.Datetime.now()).strftime('%Y-%m-%d %H:%M:%S')
            msg_done = f"✅ [Imágenes] Sincronización de catálogo finalizada con éxito el {timestamp_str}. Todos los productos cuentan con imagen o se encuentran verificados sin imagen en Shopify."
            config.write({'sku_simulation_log': msg_done})
            _logger.info("[Images] ✅ Todas las imágenes procesadas. Cron desactivado.")

    def _get_all_shopify_products(self):
        """Helper to fetch all products from Shopify."""
        self.ensure_one()
        shop_domain = self.shop_url.strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        token = self.access_token.strip() if self.access_token else ''
        
        url = f"https://{shop_domain}/admin/api/2024-01/products.json?limit=250"
        headers = {"X-Shopify-Access-Token": token}
        all_products = []
        
        while url:
            _logger.info("Cargando productos Shopify: %s", url)
            retries = 3
            while retries > 0:
                response = requests.get(url, headers=headers, timeout=20)
                if response.status_code == 429:
                    _logger.warning("Rate limit Shopify (429). Esperando 5 segundos...")
                    time.sleep(5)
                    retries -= 1
                    continue
                if response.status_code != 200:
                    _logger.error("Error API Shopify: %s", response.text)
                    raise UserError(_("Error Shopify API: %s") % response.text)
                break
            
            data = response.json()
            all_products.extend(data.get('products', []))
            
            # Pausa preventiva entre páginas para evitar 429
            time.sleep(1.0)
            
            # Pagination logic (Link header)
            link_header = response.headers.get('Link')
            url = None
            if link_header and 'rel="next"' in link_header:
                parts = link_header.split(',')
                for part in parts:
                    if 'rel="next"' in part:
                        url = part.split(';')[0].strip().strip('<>')
                        break
        return all_products

    def action_simulate_skus(self):
        self.ensure_one()
        if self.is_syncing:
            raise UserError(_("Ya hay una sincronización, simulación o auditoría en curso. Por favor espera a que termine."))

        from .sku_logic import generate_sku_for_variant
        
        # Limpiar líneas previas
        self.simulation_line_ids.unlink()
        
        self.write({
            'is_syncing': True,
            'sku_simulation_log': "🔍 [Simulación Catálogo] Iniciando análisis del catálogo de Shopify y simulación de mapeo de variantes en Odoo..."
        })
        self.env.cr.commit()
        
        # Cargar los dos mapas del CSV (por Handle y por Título)
        handle_map, title_map = self._get_csv_sku_map()
        
        products = self._get_all_shopify_products()
        simulation_vals = []
        
        for p in products:
            p_title = (p.get('title') or '').strip()
            p_type = p.get('product_type', '') or ''
            
            # Omitir productos virtuales "Free Gift" de la simulación
            if p_type == 'giftbox_ghost_product' or p_title.lower().startswith('free gift'):
                continue
                
            p_type = p.get('product_type', 'ACC')
            handle = p.get('handle', '')
            
            # Map images by ID for quick lookup
            images_list = p.get('images') or []
            product_images = {str(img['id']): img['src'] for img in images_list}
            first_image_url = images_list[0].get('src') if images_list else None
            
            for v in p.get('variants', []):
                # Normalizar: handle minúsculas y strip
                clean_handle = handle.strip().lower()
                clean_title = (p.get('title') or '').strip().lower()
                
                opt1 = (v.get('option1') or '').strip()
                opt2 = (v.get('option2') or '').strip()
                opt3 = (v.get('option3') or '').strip()
                
                csv_key_h = (clean_handle, opt1, opt2, opt3)
                csv_key_t = (clean_title, opt1, opt2, opt3)
                
                # Búsqueda híbrida: Primero por Handle, luego por Título como respaldo
                csv_sku = handle_map.get(csv_key_h) or title_map.get(csv_key_t)
                sku_source = 'logic'
                
                # Buscar el producto en Odoo para captar su stock e IDs
                # Nota: Buscamos por el SKU actual de Shopify
                odoo_variant = self.env['product.product'].search([('default_code', '=', v.get('sku'))], limit=1)
                
                if odoo_variant:
                    # Vincular IDs técnicos para Pasos 3 y 5
                    odoo_variant.write({
                        'shopify_variant_id': str(v.get('id')),
                        'shopify_inventory_item_id': str(v.get('inventory_item_id')),
                    })

                if csv_sku:
                    suggested = csv_sku
                    sku_source = 'csv'
                else:
                    # Fallback a la generación por lógica si no está en el CSV
                    suggested = generate_sku_for_variant(p, v)
                    _logger.debug("Mmatch fallido para '%s' (%s), usando lógica.", clean_handle, clean_title)
                
                # Fetch image URL (no download, just the link)
                image_id = str(v.get('image_id')) if v.get('image_id') else None
                image_url = product_images.get(image_id) or first_image_url
                
                old_sku = v.get('sku') or ''
                if old_sku == suggested:
                    sync_state = 'synced'      # SKU actual == sugerido: perfecto
                elif old_sku:
                    sync_state = 'has_sku'     # Ya tiene SKU, solo difiere del sugerido
                else:
                    sync_state = 'draft'       # Sin ningún SKU: realmente pendiente
                
                # Extraer código de categoría del SKU generado (ej: de HRS-POL-.. toma POL)
                parts = suggested.split('-')
                cat_code = parts[1] if len(parts) > 1 else 'ACC'

                # Obtener código de barras existente (priorizando Shopify, luego Odoo)
                bc = v.get('barcode') or (odoo_variant and odoo_variant.barcode) or ''
                bc_state = 'done' if bc else 'none'

                simulation_vals.append({
                    'config_id': self.id,
                    'shopify_product_id': str(p.get('id')),
                    'shopify_variant_id': str(v.get('id')),
                    'shopify_handle': clean_handle,
                    'shopify_title': clean_title,
                    'product_name': p.get('title'),
                    'variant_name': v.get('title'),
                    'product_type': p_type,
                    'old_sku': old_sku,
                    'suggested_sku': suggested,
                    'barcode': bc,
                    'generated_barcode': bc,
                    'barcode_sync_state': bc_state,
                    'sku_source': sku_source,
                    'is_selected': False,
                    'sku_category_code': cat_code,
                    'product_image': image_url,
                    'sync_state': sync_state,
                })
        
        self.env['shopify.sku.simulation.line'].create(simulation_vals)
        self.env.flush_all()
        self.env.cr.commit()

        # Lanzar la auditoría de inventario automáticamente en segundo plano para poblar stocks inmediatamente
        lineas_a_auditar = self.simulation_line_ids.filtered(lambda l: l.exists_in_odoo)
        if lineas_a_auditar:
            dbname = self.env.cr.dbname
            record_id = self.id
            line_ids = lineas_a_auditar.ids
            
            thread = threading.Thread(target=self._run_audit_thread, args=(dbname, record_id, line_ids))
            thread.start()
            
            msg = f"Simulación cargada. Auditando stocks de {len(lineas_a_auditar)} variantes en segundo plano..."
        else:
            self.write({'is_syncing': False})
            msg = "Simulación cargada con éxito. No hay productos en Odoo para auditar stock."
            
        local_now = fields.Datetime.context_timestamp(self, fields.Datetime.now()).strftime('%Y-%m-%d %H:%M:%S')
        self.sku_simulation_log = f"✅ [Simulación Catálogo] Análisis finalizado con éxito el {local_now}.\nSe han analizado y preparado {len(simulation_vals)} variantes de Shopify en la tabla."
        
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'Catálogo Simulado',
                'message': msg,
                'type': 'success',
                'sticky': False,
            }
        }
    def action_reset_sync_status(self):
        """Libera el estado de sincronización manualmente si se queda pegado."""
        self.is_syncing = False
        self.sync_progress = 0.0
        return True

    def action_apply_skus_to_shopify(self):
        self.ensure_one()
        if self.is_syncing:
            raise UserError(_("Ya hay una sincronización en marcha. Por favor espera a que termine."))
            
        # IMPORTANTE: Forzar el guardado de todos los cambios pendientes en la base de datos
        # Esto soluciona el problema de que el hilo de fondo no vea los ítems seleccionados.
        self.env.flush_all()
        
        # Solo procesar líneas seleccionadas
        lines_to_sync = self.simulation_line_ids.filtered(lambda l: l.is_selected and l.sync_state != 'synced')
        
        if not lines_to_sync:
            raise UserError(_("No hay líneas seleccionadas para sincronizar, o las seleccionadas ya están sincronizadas."))
        
        # Lanzar hilo
        threaded_sync = threading.Thread(target=self._run_shopify_sync_background, args=(self._cr.dbname, self.id))
        threaded_sync.start()
        
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Proceso Iniciado'),
                'message': _('Los SKUs se están sincronizando en segundo plano. Puedes ver el avance en el log.'),
                'type': 'success',
                'sticky': False,
            }
        }

    def action_mass_import_to_odoo(self):
        """Lanza la importación masiva en SEGUNDO PLANO para no bloquear al usuario."""
        self.ensure_one()
        if self.is_syncing:
            raise UserError(_("Ya hay una sincronización en curso. Espera a que termine."))

        # Buscamos en DB los marcados
        todas_seleccionadas = self.env['shopify.sku.simulation.line'].search([
            ('config_id', '=', self.id),
            ('is_selected', '=', True)
        ])
        
        # Filtramos en memoria los que realmente no existen
        seleccionadas = todas_seleccionadas.filtered(lambda l: not l.exists_in_odoo)
        
        if not seleccionadas:
            # Limpiamos checks de los que ya existen
            if todas_seleccionadas:
                todas_seleccionadas.write({'is_selected': False})
            raise UserError(_("No hay productos nuevos seleccionados para importar."))

        # Iniciamos el Hilo (Thread)
        self.write({
            'is_syncing': True,
            'sku_simulation_log': f"📥 [Catálogo] Iniciando la importación masiva y creación en Odoo de {len(seleccionadas)} variantes de producto seleccionadas desde Shopify..."
        })
        
        # Necesitamos el ID y el nombre de la DB para el hilo
        dbname = self.env.cr.dbname
        record_id = self.id
        line_ids = seleccionadas.ids
        
        thread = threading.Thread(target=self._run_mass_import_thread, args=(dbname, record_id, line_ids))
        thread.start()

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Proceso Iniciado'),
                'message': _('La importación de %s productos ha comenzado en segundo plano. Puedes seguir trabajando.') % len(seleccionadas),
                'type': 'success',
                'sticky': False,
            }
        }

    def _run_mass_import_thread(self, dbname, record_id, line_ids):
        """Método que corre en el hilo de fondo con su propia conexión a DB."""
        from odoo import api, SUPERUSER_ID, registry
        
        with registry(dbname).cursor() as new_cr:
            new_env = api.Environment(new_cr, SUPERUSER_ID, {})
            config = new_env['shopify.config'].browse(record_id)
            lines = new_env['shopify.sku.simulation.line'].browse(line_ids)
            
            count = 0
            total = len(lines)
            error_details = []
            
            for line in lines:
                try:
                    # Actualizar log cada 2 productos para no saturar
                    if count % 2 == 0:
                        config.write({'sku_simulation_log': f"⏳ [Catálogo] [{count+1}/{total}] Importando y registrando en Odoo: {line.product_name} (SKU: {line.old_sku})..."})
                        new_cr.commit()
                    
                    # Pausa para Shopify
                    time.sleep(0.6)
                    
                    # IMPORTAR
                    line.action_import_to_odoo()
                    
                    # Desmarcar y guardar
                    line.write({'is_selected': False})
                    count += 1
                    
                except Exception as e:
                    msg = f"❌ Error en {line.old_sku}: {str(e)}"
                    _logger.error(msg)
                    error_details.append(msg)
                    continue
                
                # Commit cada 5 productos para que el usuario vea el avance en el dashboard
                if count % 5 == 0:
                    new_cr.commit()

            final_msg = f"✅ [Catálogo] Importación masiva completada con éxito. Se crearon y registraron {count} variantes de producto en Odoo."
            if error_details:
                final_msg += f"\n⚠️ [Catálogo] Atención: {len(error_details)} variantes presentaron detalles o errores durante el proceso:\n" + "\n".join(error_details[:10])
                
            config.write({
                'sku_simulation_log': final_msg,
                'is_syncing': False
            })
            new_cr.commit()

    def action_audit_inventory(self):
        """Lanza una auditoría para comparar stock de Shopify vs Odoo en segundo plano."""
        self.ensure_one()
        if self.is_syncing:
            raise UserError(_("Ya hay un proceso en curso. Espera a que termine."))

        # Solo auditamos lo que YA está en Odoo
        lineas_a_auditar = self.simulation_line_ids.filtered(lambda l: l.exists_in_odoo)
        
        if not lineas_a_auditar:
            raise UserError(_("No hay productos en Odoo para auditar."))

        self.write({
            'is_syncing': True,
            'sku_simulation_log': f"📋 [Auditoría Stock] Iniciando auditoría y comparación de existencias físicas para {len(lineas_a_auditar)} variantes..."
        })
        
        dbname = self.env.cr.dbname
        record_id = self.id
        line_ids = lineas_a_auditar.ids
        
        thread = threading.Thread(target=self._run_audit_thread, args=(dbname, record_id, line_ids))
        thread.start()

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Auditoría Iniciada'),
                'message': _('Comparando Shopify vs Odoo en segundo plano...'),
                'type': 'warning',
                'sticky': False,
            }
        }

    def _run_audit_thread(self, dbname, record_id, line_ids):
        """Hilo de fondo para comparar stocks con alta estabilidad."""
        from odoo import api, SUPERUSER_ID, registry
        import time
        import traceback
        
        with api.Environment.manage():
            new_cr = registry(dbname).cursor()
            try:
                new_env = api.Environment(new_cr, SUPERUSER_ID, {})
                config = new_env['shopify.config'].browse(record_id)
                lines = new_env['shopify.sku.simulation.line'].browse(line_ids)
                
                discrepancies = []
                count = 0
                total = len(lines)
                
                _logger.info("INICIANDO AUDITORÍA POR LOTES: %s variantes", total)
                
                # 1. Asegurar IDs de inventario (Pre-fetch Optimizado)
                lines_missing_id = lines.filtered(lambda l: not l.shopify_inventory_item_id or l.shopify_inventory_item_id == '0')
                if lines_missing_id:
                    self._safe_write_log(config, f"🔍 [Auditoría Stock] Preparando IDs de inventario ({len(lines_missing_id)} variantes pendientes)...", 0.0)
                    
                    # Agrupamos por ID de producto de Shopify para hacer menos llamadas
                    product_ids = list(set(lines_missing_id.mapped('shopify_product_id')))
                    total_p = len(product_ids)
                    
                    headers = {'X-Shopify-Access-Token': config.access_token}
                    shop_domain = config.shop_url.strip().lower().replace('https://', '').replace('http://', '').split('/')[0]
                    
                    for idx, p_id in enumerate(product_ids, 1):
                        if not p_id: continue
                        url_p = f"https://{shop_domain}/admin/api/2024-01/products/{p_id}.json"
                        try:
                            resp_p = requests.get(url_p, headers=headers, timeout=15)
                            if resp_p.status_code == 200:
                                p_data = resp_p.json().get('product', {})
                                for var in p_data.get('variants', []):
                                    v_id = str(var.get('id'))
                                    inv_id = str(var.get('inventory_item_id', '0'))
                                    # Actualizamos todas las líneas que coincidan con esta variante
                                    target_lines = lines_missing_id.filtered(lambda l: l.shopify_variant_id == v_id)
                                    if target_lines and inv_id != '0':
                                        target_lines.write({'shopify_inventory_item_id': inv_id})
                            
                            if idx % 5 == 0:
                                self._safe_write_log(config, f"🔍 [Auditoría Stock] Mapeando IDs de inventario ({idx}/{total_p} modelos preparados)...", (idx / total_p) * 15.0)
                                new_cr.commit()
                        except:
                            pass
                        time.sleep(0.4) # Respeto al Rate Limit
                
                # 2. Auditoría por lotes de 50
                BATCH_SIZE = 50
                interested_location_ids = [str(m.shopify_location_id) for m in config.location_mapping_ids if m.shopify_location_id]
                headers = {'X-Shopify-Access-Token': config.access_token}
                shop_domain = config.shop_url.strip().lower().replace('https://', '').replace('http://', '').split('/')[0]

                for i in range(0, total, BATCH_SIZE):
                    batch = lines[i:i + BATCH_SIZE]
                    progress = 15.0 + (((i + len(batch)) / total) * 85.0)
                    self._safe_write_log(config, f"📊 [Auditoría Stock] Comparando niveles de inventario físico ({i + len(batch)}/{total})...", progress)

                    inv_ids = [l.shopify_inventory_item_id for l in batch if l.shopify_inventory_item_id and l.shopify_inventory_item_id != '0']
                    if not inv_ids: continue
                        
                    url = f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={','.join(inv_ids)}&limit=250"
                    try:
                        resp = requests.get(url, headers=headers, timeout=25)
                        if resp.status_code == 200:
                            levels = resp.json().get('inventory_levels', [])
                            stock_data = {}
                            for lvl in levels:
                                item_id, loc_id = str(lvl['inventory_item_id']), str(lvl['location_id'])
                                if loc_id in interested_location_ids:
                                    stock_data[item_id] = stock_data.get(item_id, 0) + (lvl.get('available') or 0)
                            
                            for line in batch:
                                shop_qty = stock_data.get(str(line.shopify_inventory_item_id), 0)
                                product = new_env['product.product'].search([('default_code', '=', line.old_sku)], limit=1)
                                odoo_qty = product.qty_available if product else 0
                                line.write({'shopify_qty_total': shop_qty})
                                if float(shop_qty) != float(odoo_qty):
                                    discrepancies.append(f"⚠️ {line.old_sku}: Shopify({shop_qty}) vs Odoo({odoo_qty})")
                        else:
                            discrepancies.append(f"❌ Error API Lote {i//BATCH_SIZE + 1}: {resp.status_code}")
                    except Exception as e:
                        discrepancies.append(f"❌ Excepción Lote {i//BATCH_SIZE + 1}: {str(e)}")
                    time.sleep(0.8)

                if not discrepancies:
                    final_msg = f"✅ [Auditoría Stock] Auditoría completada. ¡Perfecto! Las {total} variantes coinciden exactamente entre Shopify y Odoo."
                else:
                    final_msg = f"⚠️ [Auditoría Stock] Auditoría completada. Se detectaron {len(discrepancies)} discrepancias de stock entre plataformas:\n" + "\n".join(discrepancies[:50])
                    if len(discrepancies) > 50: final_msg += f"\n... y {len(discrepancies) - 50} más."

                self._safe_write_log(config, final_msg, 100.0)
                config.write({'is_syncing': False})
                new_cr.commit()
                
            except Exception as e_outer:
                error_trace = traceback.format_exc()
                _logger.error("ERROR CRÍTICO EN AUDITORÍA:\n%s", error_trace)
                try:
                    with registry(dbname).cursor() as err_cr:
                        err_env = api.Environment(err_cr, SUPERUSER_ID, {})
                        err_config = err_env['shopify.config'].browse(record_id)
                        err_config.write({
                            'sku_simulation_log': f"❌ [Auditoría Stock] ERROR CRÍTICO: {str(e_outer)}\nEl proceso se detuvo inesperadamente. Por favor libera el bloqueo manual e inténtalo de nuevo.",
                            'is_syncing': False
                        })
                        err_cr.commit()
                except:
                    pass
            finally:
                new_cr.close()

    def _safe_write_log(self, config, msg, progress=None):
        """Escribe en el log de forma segura manejando reintentos por concurrencia."""
        import time
        max_retries = 3
        for i in range(max_retries):
            try:
                vals = {'sku_simulation_log': msg}
                if progress is not None:
                    vals['sync_progress'] = progress
                config.write(vals)
                config.env.cr.commit()
                return True
            except Exception as e:
                config.env.cr.rollback()
                if "concurrent update" in str(e).lower() or "could not serialize" in str(e).lower():
                    time.sleep(0.5)
                    continue
                _logger.error("Error no recuperable en safe_write_log: %s", str(e))
                break
        return False

    def action_release_lock(self):
        """Libera el bloqueo de sincronización si se queda trabado."""
        self.ensure_one()
        self.write({
            'is_syncing': False,
            'sku_simulation_log': '🔓 Bloqueo liberado manualmente. El proceso anterior fue cancelado o se detuvo.'
        })
        return True

    def action_select_all_pending(self):
        """Marca solo los que tienen discrepancias o no existen en Odoo."""
        self.ensure_one()
        to_select = self.simulation_line_ids.filtered(lambda l: not l.exists_in_odoo or l.shopify_qty_total != l.odoo_qty_total)
        to_select.write({'is_selected': True})
        return True

    def action_unselect_all(self):
        """Desmarca todos los registros."""
        self.ensure_one()
        self.simulation_line_ids.write({'is_selected': False})
        return True

    def action_select_stock_discrepancies(self):
        """Marca SOLO los productos ya en Odoo que tienen diferencia de cantidades con Shopify."""
        self.ensure_one()
        to_select = self.simulation_line_ids.filtered(
            lambda l: l.exists_in_odoo and l.shopify_qty_total != l.odoo_qty_total
        )
        to_select.write({'is_selected': True})
        count = len(to_select)
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': 'Discrepancias Marcadas',
                'message': f'{count} productos con diferencia de cantidades seleccionados.',
                'type': 'info',
                'sticky': False,
            }
        }

    def action_update_stock_from_shopify(self):
        """
        Actualiza las cantidades en Odoo usando Shopify como fuente de verdad.
        Solo afecta productos YA importados y seleccionados.
        """
        self.ensure_one()
        selected = self.simulation_line_ids.filtered(lambda l: l.is_selected and l.exists_in_odoo)
        if not selected:
            raise UserError("Selecciona al menos un producto ya importado a Odoo para actualizar su stock.")
        self.write({'is_syncing': True, 'sku_simulation_log': f'🔄 [Stock] Actualizando existencias físicas en Odoo para {len(selected)} productos seleccionados basándose en Shopify...'})
        self.env.cr.commit()
        import threading
        threading.Thread(
            target=self._run_inventory_sync_background,
            args=(self.env.cr.dbname, self.id),
            daemon=True
        ).start()
        return True

    def action_sync_inventory_selected(self):
        """Lanza la sincronización masiva de inventario."""
        self.ensure_one()
        selected = self.simulation_line_ids.filtered(lambda l: l.is_selected)
        if not selected: raise UserError("No hay productos seleccionados.")
        self.write({'is_syncing': True, 'sync_progress': 0.0})
        self.env.cr.commit()
        threading.Thread(target=self._run_inventory_sync_background, args=(self.env.cr.dbname, self.id), daemon=True).start()
        return True

    def action_import_orders(self):
        """Lanza la importación manual de pedidos."""
        self.ensure_one()
        if not self.order_pricelist_id or not self.order_journal_id:
            raise UserError("Por favor configura la Lista de Precios y el Diario de Pagos en la pestaña de Ventas.")
        
        self.write({'is_syncing': True, 'sku_simulation_log': '📥 [Importación Ventas] Iniciando conexión con Shopify para importar pedidos de venta recientes de forma manual...'})
        self.env.cr.commit()
        # Pasamos el ID del usuario actual para que los pedidos le pertenezcan
        threading.Thread(target=self._run_order_sync_background, args=(self.env.cr.dbname, self.id, self.env.user.id), daemon=True).start()
        return True

    def _import_single_shopify_order(self, s_order):
        """
        Procesa un único pedido de Shopify y lo crea en Odoo de forma robusta.
        Si está pagado y auto_create_draft_invoice está activo, crea automáticamente la factura borrador interna.
        """
        self.ensure_one()
        shopify_id = str(s_order.get('id'))
        order_name = s_order.get('name') or shopify_id

        # 1. Duplicados - Si ya existe en Odoo, actualizamos su estado financiero y de reembolso de Shopify
        existing = self.env['sale.order'].search(['|', ('shopify_order_id', '=', shopify_id), ('origin', '=', order_name)], limit=1)
        if existing:
            # Datos de Envío y Reembolsos de Shopify
            shipping_title = ''
            shipping_lines = s_order.get('shipping_lines', [])
            if shipping_lines:
                shipping_title = shipping_lines[0].get('title', '')

            financial_status = s_order.get('financial_status') or ''
            refund_status = 'no_refund'
            if financial_status == 'refunded':
                refund_status = 'refunded'
            elif financial_status == 'partially_refunded':
                refund_status = 'partially_refunded'

            refund_amount = 0.0
            refund_ids_list = []
            for r in s_order.get('refunds', []):
                refund_ids_list.append(str(r.get('id')))
                for t in r.get('transactions', []):
                    if t.get('status') == 'success':
                        refund_amount += float(t.get('amount') or 0.0)
            refund_ids = ','.join(refund_ids_list) if refund_ids_list else ''

            raw_fulfillment = s_order.get('fulfillment_status') or 'pending'
            fulfillment_status = raw_fulfillment if raw_fulfillment in ['pending', 'fulfilled', 'partial', 'restocked', 'error'] else 'pending'

            existing.write({
                'shopify_financial_status': financial_status,
                'shopify_refund_status': refund_status,
                'shopify_refund_amount': refund_amount,
                'shopify_refund_ids': refund_ids,
                'shopify_shipping_title': shipping_title,
                'shopify_fulfillment_status': fulfillment_status,
            })

            # --- AUTO-CONFIRMACIÓN DE BORRADORES (Pagos contra entrega / Pending) ---
            if existing.state in ['draft', 'sent'] and financial_status in ['paid', 'pending', 'partially_paid'] and not s_order.get('cancelled_at'):
                try:
                    existing.action_confirm()
                    _logger.info(f"[Shopify Sync] Orden {existing.name} auto-confirmada al actualizar estado a {financial_status}.")
                except Exception as e:
                    _logger.warning(f"[Shopify Sync] No se pudo auto-confirmar la orden {existing.name}: {e}")

            # --- AUTO-TRASPASO POR ACTUALIZACIÓN DE ENVÍO ---
            title_upper = (shipping_title or '').upper()
            if existing.state in ['sale', 'done'] and any(x in title_upper for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
                try:
                    existing._create_pickup_internal_transfer()
                except Exception as e:
                    _logger.error("[Shopify BOPIS] Error al crear traspaso interno automático para '%s' tras actualización: %s", existing.name, str(e))
            
            # --- AUTO-CANCELACIÓN PRO (Liberación de Stock) ---
            is_fully_refunded_undelivered = False
            if s_order.get('financial_status') == 'refunded':
                # Buscar si hay algún albarán ya validado (hecho) para este pedido
                done_pickings = self.env['stock.picking'].search([
                    '|', ('sale_id', '=', existing.id), ('origin', '=', existing.name),
                    ('state', '=', 'done')
                ])
                if not done_pickings:
                    is_fully_refunded_undelivered = True

            if (s_order.get('cancelled_at') or is_fully_refunded_undelivered) and existing.state != 'cancel':
                # Buscar cualquier albarán/traslado asociado por sale_id o por origin (documento origen)
                pending_pickings = self.env['stock.picking'].search([
                    '|', ('sale_id', '=', existing.id), ('origin', '=', existing.name),
                    ('state', 'not in', ['done', 'cancel'])
                ])
                for picking in pending_pickings:
                    picking.action_cancel()
                try:
                    existing.with_context(disable_cancel_warning=True).action_cancel()
                    _logger.info(f"[Shopify Sync] Orden {existing.name} cancelada automáticamente desde Shopify debido a cancelación o reembolso total pre-envío.")
                except Exception as e:
                    _logger.warning(f"[Shopify Sync] No se pudo cancelar la orden {existing.name}: {e}")
            
            # --- AUTO-VALIDACIÓN PRO (Para actualizaciones si cambió a Fulfilled) ---
            if s_order.get('fulfillment_status') == 'fulfilled' and existing.state in ['sale', 'done']:
                for picking in existing.picking_ids.filtered(lambda p: p.state not in ['done', 'cancel']):
                    # Extraer información de guía si existe en Shopify para auto-validar
                    tracking_number = False
                    tracking_company = False
                    tracking_url = False
                    for f in s_order.get('fulfillments', []):
                        if f.get('status') == 'success' or not f.get('status'):
                            tracking_number = f.get('tracking_number')
                            tracking_company = f.get('tracking_company')
                            tracking_url = f.get('tracking_url')
                            if tracking_number:
                                break
                    
                    if tracking_number:
                        vals = {
                            'carrier_tracking_ref': tracking_number,
                        }
                        if tracking_url:
                            vals['carrier_tracking_url'] = tracking_url
                        if tracking_company:
                            carrier = self.env['delivery.carrier'].search([('name', 'ilike', tracking_company)], limit=1)
                            if not carrier:
                                carrier = self.env['delivery.carrier'].search([('name', 'ilike', 'DHL')], limit=1)
                            if carrier:
                                vals['carrier_id'] = carrier.id
                        picking.write(vals)

                    picking.action_assign()
                    for move in picking.move_ids.filtered(lambda m: m.state not in ['done', 'cancel']):
                        move.quantity_done = move.product_uom_qty
                    try:
                        picking.with_context(skip_sms=True).button_validate()
                    except Exception as e:
                        _logger.warning(f"[Shopify Auto-Validate] No se pudo auto-validar albarán {picking.name}: {e}")
            # 1.1. Buscar e insertar nuevos artículos agregados (como cambios de talla / intercambios en Shopify)
            added_products = []
            not_found_items = []
            for item in s_order.get('line_items', []):
                sku = item.get('sku')
                product = self.env['product.product'].search([('default_code', '=', sku)], limit=1) if sku else False
                if not product:
                    variant_id = str(item.get('variant_id') or '')
                    product = self.env['product.product'].search([('shopify_variant_id', '=', variant_id)], limit=1)
                

                # ── ALERTA 3: Producto no encontrado en Odoo ──
                if not product:
                    not_found_items.append(item)
                    _logger.warning(
                        "[Shopify Sync] Producto no encontrado en Odoo para pedido %s: SKU=%s, Variant=%s, Título=%s",
                        order_name, sku, item.get('variant_id'), item.get('title')
                    )
                    continue

                if product:
                    # Verificar si este producto ya existe en las líneas del pedido
                    existing_line = existing.order_line.filtered(lambda l: l.product_id.id == product.id)
                    if not existing_line:
                        # Este es un nuevo artículo (como una talla de cambio en un "Return and Exchange")
                        qty = float(item.get('quantity', 1))
                        price = float(item.get('price', 0.0))
                        
                        # Extraer el descuento
                        discount_amount = sum(float(alloc.get('amount', 0.0)) for alloc in item.get('discount_allocations', []))
                        discount_percent = 0.0
                        if price > 0 and qty > 0 and discount_amount > 0:
                            discount_percent = (discount_amount / (price * qty)) * 100.0
                        
                        _logger.info(
                            "[Shopify Exchange] Detectado nuevo artículo de cambio en Shopify %s: %s (SKU: %s). "
                            "Agregándolo al pedido Odoo %s.",
                            order_name, product.name, sku, existing.name
                        )
                        existing.write({
                            'order_line': [(0, 0, {
                                'name': item.get('name') or product.name,
                                'product_id': product.id,
                                'product_uom_qty': qty,
                                'price_unit': price,
                                'discount': round(discount_percent, 2),
                                'tax_id': [],
                            })]
                        })
                        added_products.append(f"{product.name} (SKU: {sku or 'N/A'})")
                        # Si es un pedido de retiro en oficina, crear traspaso interno automático para el nuevo producto
                        ship_title = (existing.shopify_shipping_title or '').upper()
                        if any(x in ship_title for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
                            try:
                                existing._create_pickup_internal_transfer()
                            except Exception as e:
                                _logger.error("[Shopify BOPIS] Error al crear traspaso interno automático para artículo de cambio en '%s': %s", existing.name, str(e))
                    else:
                        # Si ya existe el artículo, validamos si cambió la cantidad total en Shopify para actualizarla.
                        # BUGFIX: Shopify manda líneas separadas para cambios (exchanges) del mismo producto.
                        # Sumamos la cantidad total de este producto en todo el payload de Shopify.
                        total_shopify_qty = 0
                        for si in s_order.get('line_items', []):
                            si_sku = si.get('sku')
                            si_vid = str(si.get('variant_id') or '')
                            if (si_sku and si_sku == product.default_code) or \
                               (si_vid and si_vid == product.shopify_variant_id):
                                total_shopify_qty += float(si.get('quantity', 1))

                        total_odoo_qty = sum(existing_line.mapped('product_uom_qty'))

                        # Solo actualizamos si la cantidad difiere y el pedido en Odoo no está en estado borrador (draft) donde se sobreescribiría de todos modos
                        if total_odoo_qty != total_shopify_qty:
                            old_qty = total_odoo_qty
                            _logger.info(
                                "[Shopify Edit] Detectada actualización de cantidad para %s en Shopify %s: %s ➔ %s. "
                                "Actualizando en Odoo %s.",
                                product.name, order_name, old_qty, total_shopify_qty, existing.name
                            )
                            # Absorbemos la diferencia en la primera línea encontrada
                            new_line_qty = existing_line[0].product_uom_qty + (total_shopify_qty - total_odoo_qty)
                            existing_line[0].write({'product_uom_qty': new_line_qty})

                            # ── Actualizar traslado interno (BOPIS) si es pedido de retiro ──
                            ship_title_update = (existing.shopify_shipping_title or '').upper()
                            if any(x in ship_title_update for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
                                try:
                                    existing._update_pickup_internal_transfer_qty(product, old_qty, total_shopify_qty)
                                except Exception as e:
                                    _logger.error(
                                        "[Shopify BOPIS Qty] Error al actualizar traslado interno para '%s' producto %s: %s",
                                        existing.name, product.display_name, str(e)
                                    )

            # ── ALERTA 1: Detectar productos REMOVIDOS del pedido en Shopify ──
            # Comparamos los productos que tiene Odoo vs los que Shopify reporta.
            # Si un producto existe en Odoo pero no en Shopify, es una edición/sustitución.
            shopify_product_ids = set()
            for item in s_order.get('line_items', []):
                sku = item.get('sku')
                prod = self.env['product.product'].search([('default_code', '=', sku)], limit=1) if sku else False
                if not prod:
                    vid = str(item.get('variant_id') or '')
                    prod = self.env['product.product'].search([('shopify_variant_id', '=', vid)], limit=1)
                if not prod:
                    t = item.get('title')
                    prod = self.env['product.product'].search([('name', 'ilike', t)], limit=1) if t else False
                if prod:
                    shopify_product_ids.add(prod.id)

            removed_products = []
            for line in existing.order_line.filtered(lambda l: not l.display_type and l.product_id and l.product_id.type == 'product'):
                # Ignorar el producto de envío
                if line.product_id.default_code == 'ENVIO':
                    continue
                if line.product_id.id not in shopify_product_ids and line.product_uom_qty > 0:
                    removed_products.append(line)

            if removed_products or added_products or not_found_items:
                # Construir mensaje de alerta
                alert_parts = []
                if removed_products:
                    alert_parts.append("<b>Productos REMOVIDOS en Shopify</b> (siguen activos en Odoo):")
                    for line in removed_products:
                        # Verificar si tiene traslados/pickings pendientes
                        pending_moves = self.env['stock.move'].search([
                            ('product_id', '=', line.product_id.id),
                            '|', ('picking_id.sale_id', '=', existing.id), ('picking_id.origin', '=', existing.name),
                            ('state', 'not in', ['done', 'cancel'])
                        ])
                        picking_warning = ""
                        if pending_moves:
                            picking_names = ', '.join(pending_moves.mapped('picking_id.name'))
                            picking_warning = f" — ⚠️ <b>Tiene traslados pendientes: {picking_names}</b>"
                        alert_parts.append(
                            f"&nbsp;&nbsp;❌ {line.product_id.name} "
                            f"(SKU: {line.product_id.default_code or 'N/A'}, Qty: {int(line.product_uom_qty)})"
                            f"{picking_warning}"
                        )
                if added_products:
                    alert_parts.append("<b>Productos AGREGADOS desde Shopify:</b>")
                    for name in added_products:
                        alert_parts.append(f"&nbsp;&nbsp;✅ {name}")
                if not_found_items:
                    alert_parts.append("<b>Productos de Shopify NO ENCONTRADOS en Odoo:</b>")
                    for nf in not_found_items:
                        alert_parts.append(
                            f"&nbsp;&nbsp;🔍 {nf.get('title', 'Sin título')} "
                            f"(SKU: {nf.get('sku') or 'N/A'}, Variant: {nf.get('variant_id') or 'N/A'})"
                        )

                if removed_products:
                    alert_parts.append("")
                    alert_parts.append(
                        "⚡ <b>Acción requerida:</b> Revisa los traslados/pickings pendientes de los productos "
                        "removidos y cancélalos manualmente si ya no aplican."
                    )

                existing.message_post(
                    body="🔄 <b>Shopify Edit Detectado</b><br/><br/>" + "<br/>".join(alert_parts),
                    subtype_xmlid="mail.mt_note",
                    message_type='notification',
                )
                _logger.info("[Shopify Alert] Alerta de edición publicada en chatter para %s", existing.name)

            self._process_shopify_refunds_automation_logic(existing, s_order)
            return existing

        # 2. Cliente (Búsqueda inteligente: Email → Nombre → Genérico POS)
        customer_data = s_order.get('customer') or {}
        email = customer_data.get('email') or s_order.get('email')
        name_parts = f"{customer_data.get('first_name', '')} {customer_data.get('last_name', '')}".strip()

        # ── Caso: Venta POS sin cuenta de cliente (email ni nombre real disponibles)
        # En lugar de crear "Cliente Shopify #XXXX" por cada orden, reutilizamos
        # UN contacto genérico para no contaminar la lista de contactos de Odoo.
        if not email and not name_parts:
            GENERIC_NAME = 'Cliente POS Shopify'
            partner = self.env['res.partner'].search([('name', '=', GENERIC_NAME)], limit=1)
            if not partner:
                partner = self.env['res.partner'].create({
                    'name': GENERIC_NAME,
                    'comment': 'Contacto genérico para ventas de Punto de Venta en Shopify sin cuenta de cliente registrada.',
                    'customer_rank': 1,
                })

        # ── Caso normal: cliente con email o nombre identificable
        else:
            name = name_parts or f"Cliente Shopify {order_name}"
            partner = False

            if email and email.strip():
                partner = self.env['res.partner'].search([('email', '=', email)], limit=1)

            if not partner and name_parts:
                partner = self.env['res.partner'].search([('name', '=', name_parts)], limit=1)

            # Obtener datos de dirección (envío, facturación o por defecto del cliente)
            address_data = s_order.get('shipping_address') or s_order.get('billing_address') or customer_data.get('default_address') or {}
            
            # Buscar país en Odoo
            country = False
            country_code = (address_data.get('country_code') or '').strip().upper()
            country_name = (address_data.get('country') or '').strip().lower()
            if country_code:
                country = self.env['res.country'].sudo().search([('code', '=', country_code)], limit=1)
            if not country and country_name:
                country = self.env['res.country'].sudo().search([('name', 'ilike', country_name)], limit=1)
                
            # Buscar provincia/estado en Odoo
            state = False
            state_code = (address_data.get('province_code') or '').strip().upper()
            state_name = (address_data.get('province') or '').strip().lower()
            if country:
                if state_code:
                    state = self.env['res.country.state'].sudo().search([
                        ('code', '=', state_code),
                        ('country_id', '=', country.id)
                    ], limit=1)
                if not state and state_name:
                    state = self.env['res.country.state'].sudo().search([
                        ('name', 'ilike', state_name),
                        ('country_id', '=', country.id)
                    ], limit=1)

            phone_val = address_data.get('phone') or customer_data.get('phone') or s_order.get('phone')
            mobile_val = address_data.get('phone') if address_data.get('phone') != (customer_data.get('phone') or s_order.get('phone')) else False

            if not partner:
                partner_vals = {
                    'name': name,
                    'email': email,
                    'phone': phone_val,
                    'mobile': mobile_val,
                    'street': address_data.get('address1'),
                    'street2': address_data.get('address2'),
                    'city': address_data.get('city'),
                    'zip': address_data.get('zip'),
                    'country_id': country.id if country else False,
                    'state_id': state.id if state else False,
                    'comment': f"Creado desde Shopify {order_name}",
                    'customer_rank': 1,
                }
                partner = self.env['res.partner'].create(partner_vals)
            else:
                # Si el contacto ya existe en Odoo, actualizamos los campos de dirección y teléfono que estén vacíos
                update_vals = {}
                if not partner.street and address_data.get('address1'):
                    update_vals['street'] = address_data.get('address1')
                if not partner.street2 and address_data.get('address2'):
                    update_vals['street2'] = address_data.get('address2')
                if not partner.city and address_data.get('city'):
                    update_vals['city'] = address_data.get('city')
                if not partner.zip and address_data.get('zip'):
                    update_vals['zip'] = address_data.get('zip')
                if not partner.country_id and country:
                    update_vals['country_id'] = country.id
                if not partner.state_id and state:
                    update_vals['state_id'] = state.id
                if not partner.phone and phone_val:
                    update_vals['phone'] = phone_val
                if not partner.mobile and mobile_val:
                    update_vals['mobile'] = mobile_val
                
                if update_vals:
                    partner.write(update_vals)
        order_lines = []
        for item in s_order.get('line_items', []):
            sku = item.get('sku')
            product = self.env['product.product'].search([('default_code', '=', sku)], limit=1) if sku else False
            if not product:
                variant_id = str(item.get('variant_id') or '')
                product = self.env['product.product'].search([('shopify_variant_id', '=', variant_id)], limit=1)
            
            if not product:
                title = item.get('title')
                product = self.env['product.product'].search([('name', 'ilike', title)], limit=1) if title else False

            if not product:
                product = self.env['product.product'].search([('default_code', '=', 'SHOPIFY-GENERIC')], limit=1)
            
            if not product:
                product = self.env['product.product'].create({
                    'name': item.get('title', 'Producto Shopify'),
                    'default_code': item.get('sku') or 'SHOPIFY-GENERIC',
                    'type': 'product',
                    'taxes_id': [],
                })

            qty = float(item.get('quantity', 1))
            price = float(item.get('price', 0.0))
            
            # Extraer el descuento aplicado a esta línea
            discount_amount = sum(float(alloc.get('amount', 0.0)) for alloc in item.get('discount_allocations', []))
            discount_percent = 0.0
            if price > 0 and qty > 0 and discount_amount > 0:
                discount_percent = (discount_amount / (price * qty)) * 100.0

            order_lines.append((0, 0, {
                'name': item.get('name') or product.name,
                'product_id': product.id,
                'product_uom_qty': qty,
                'price_unit': price,
                'discount': round(discount_percent, 2),
                'tax_id': [],
            }))

        # 4. Envío
        for ship in s_order.get('shipping_lines', []):
            price = float(ship.get('price', 0.0))
            if price > 0:
                ship_prod = getattr(self, 'shipping_product_id', None) or self.env['product.product'].search([('default_code', '=', 'ENVIO')], limit=1)
                if not ship_prod:
                    tmpl = self.env['product.template'].create({
                        'name': 'Envío / Shipping',
                        'default_code': 'ENVIO',
                        'type': 'service',
                        'sale_ok': True,
                        'purchase_ok': False,
                        'taxes_id': [],
                    })
                    ship_prod = self.env['product.product'].search([('product_tmpl_id', '=', tmpl.id)], limit=1)
                
                order_lines.append((0, 0, {
                    'name': f"Envío: {ship.get('title')}",
                    'product_id': ship_prod.id,
                    'product_uom_qty': 1,
                    'price_unit': price,
                    'tax_id': [],
                }))

        # Notas y Referencias
        order_notes = []
        if s_order.get('source_name') == 'pos':
            receipt = s_order.get('source_identifier') or s_order.get('receipt_number')
            if receipt:
                order_notes.append(f"Venta POS - Recibo: {receipt}")
        
        note = s_order.get('note')
        if note:
            order_notes.append(f"Nota del cliente: {note}")
            
        # Códigos de descuento
        discount_codes = s_order.get('discount_codes', [])
        if discount_codes:
            codes = ', '.join([f"{d.get('code')} (-{d.get('amount')})" for d in discount_codes])
            order_notes.append(f"Cupones: {codes}")
            
        for d_app in s_order.get('discount_applications', []):
            code = d_app.get('code') or d_app.get('title')
            if code and f"Cupones:" not in '\n'.join(order_notes):
                order_notes.append(f"Cupón aplicado: {code}")
                
        if order_notes:
            order_lines.append((0, 0, {
                'display_type': 'line_note',
                'name': '\n'.join(order_notes)
            }))

        # 5.5. Datos de Envío y Reembolsos de Shopify
        shipping_title = ''
        shipping_lines = s_order.get('shipping_lines', [])
        if shipping_lines:
            shipping_title = shipping_lines[0].get('title', '')

        # 5. Resolver Almacén (Con Soporte para Retiro en Oficina / BOPIS)
        is_pickup = False
        if shipping_title:
            title_upper = shipping_title.upper()
            if any(x in title_upper for x in ['OFICINA', 'RETIRO', 'PICKUP', 'SUCURSAL', 'TIENDA']):
                is_pickup = True

        warehouse = self.default_warehouse_id or self.env['stock.warehouse'].search([], limit=1)
        if is_pickup:
            # Buscar mapeo que corresponda al almacén de la oficina
            map_loc = self.env['shopify.location.mapping'].search([
                '|',
                ('shopify_location_name', 'ilike', 'OFICINA'),
                ('shopify_location_name', 'ilike', 'PICKUP')
            ], limit=1)
            if map_loc and map_loc.odoo_warehouse_id:
                warehouse = map_loc.odoo_warehouse_id
            else:
                # Fallback: buscar cualquier almacén con "OFICINA" en el nombre
                office_wh = self.env['stock.warehouse'].search([('name', 'ilike', 'OFICINA')], limit=1)
                if office_wh:
                    warehouse = office_wh
        else:
            loc_id = str(s_order.get('location_id') or '')
            if loc_id:
                map_loc = self.env['shopify.location.mapping'].search([('shopify_location_id', '=', loc_id)], limit=1)
                if map_loc and map_loc.odoo_warehouse_id:
                    warehouse = map_loc.odoo_warehouse_id

        financial_status = s_order.get('financial_status') or ''
        refund_status = 'no_refund'
        if financial_status == 'refunded':
            refund_status = 'refunded'
        elif financial_status == 'partially_refunded':
            refund_status = 'partially_refunded'

        refund_amount = 0.0
        refund_ids_list = []
        for r in s_order.get('refunds', []):
            refund_ids_list.append(str(r.get('id')))
            for t in r.get('transactions', []):
                if t.get('status') == 'success':
                    refund_amount += float(t.get('amount') or 0.0)
        refund_ids = ','.join(refund_ids_list) if refund_ids_list else ''

        raw_fulfillment = s_order.get('fulfillment_status') or 'pending'
        fulfillment_status = raw_fulfillment if raw_fulfillment in ['pending', 'fulfilled', 'partial', 'restocked', 'error'] else 'pending'

        # 6. Crear Pedido de Venta
        new_order = self.env['sale.order'].create({
            'partner_id': partner.id,
            'origin': order_name,
            'shopify_order_id': shopify_id,
            'shopify_order_number': order_name,
            'shopify_financial_status': financial_status,
            'shopify_refund_status': refund_status,
            'shopify_refund_amount': refund_amount,
            'shopify_refund_ids': refund_ids,
            'shopify_shipping_title': shipping_title,
            'shopify_fulfillment_status': fulfillment_status,
            'warehouse_id': warehouse.id,
            'order_line': order_lines,
            'team_id': self.sales_team_id.id if self.sales_team_id else False,
            'pricelist_id': self.order_pricelist_id.id if self.order_pricelist_id else False,
        })
        
        # Parsear fecha de creación de Shopify (convertir ISO a UTC ingenuo)
        created_at_str = s_order.get('created_at')
        if created_at_str:
            try:
                from dateutil import parser as dt_parser
                dt = dt_parser.parse(created_at_str)
                new_order.date_order = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
            except Exception:
                new_order.date_order = datetime.datetime.now()
        else:
            new_order.date_order = datetime.datetime.now()

        # 7. Confirmar automáticamente si está pagado o pendiente (contra entrega)
        financial_status = s_order.get('financial_status')
        if financial_status in ['paid', 'pending', 'partially_paid'] and not s_order.get('cancelled_at'):
            new_order.action_confirm()

            # --- LÓGICA DE FECHA DE CORTE (Histórico vs Nuevo) ---
            is_old_order = False
            if self.shopify_import_cutoff_date and new_order.date_order:
                if new_order.date_order < self.shopify_import_cutoff_date:
                    is_old_order = True

            if is_old_order:
                # Pedido Viejo: Cancelar las órdenes de salida para proteger inventario actual
                for picking in new_order.picking_ids.filtered(lambda p: p.picking_type_id.code == 'outgoing' and p.state not in ['done', 'cancel']):
                    picking.action_cancel()
                    _logger.info(f"[Shopify Cut-off] Albarán {picking.name} cancelado automáticamente (pedido anterior a fecha de corte).")
            else:
                # --- AUTO-VALIDACIÓN PRO PARA POS Y FULFILLED (Pedidos Nuevos) ---
                if s_order.get('fulfillment_status') == 'fulfilled' or str(s_order.get('source_name') or '').lower() == 'pos':
                    for picking in new_order.picking_ids.filtered(lambda p: p.picking_type_id.code == 'outgoing' and p.state not in ['done', 'cancel']):
                        # Extraer información de guía si existe en Shopify para auto-validar
                        tracking_number = False
                        tracking_company = False
                        tracking_url = False
                        for f in s_order.get('fulfillments', []):
                            if f.get('status') == 'success' or not f.get('status'):
                                tracking_number = f.get('tracking_number')
                                tracking_company = f.get('tracking_company')
                                tracking_url = f.get('tracking_url')
                                if tracking_number:
                                    break
                        
                        if tracking_number:
                            vals = {
                                'carrier_tracking_ref': tracking_number,
                            }
                            if tracking_url:
                                vals['carrier_tracking_url'] = tracking_url
                            if tracking_company:
                                carrier = self.env['delivery.carrier'].search([('name', 'ilike', tracking_company)], limit=1)
                                if not carrier:
                                    carrier = self.env['delivery.carrier'].search([('name', 'ilike', 'DHL')], limit=1)
                                if carrier:
                                    vals['carrier_id'] = carrier.id
                            picking.write(vals)

                        picking.action_assign()
                        for move in picking.move_ids.filtered(lambda m: m.state not in ['done', 'cancel']):
                            move.quantity_done = move.product_uom_qty
                        try:
                            picking.with_context(skip_sms=True, skip_backorder=True, skip_immediate=True).button_validate()
                            _logger.info(f"[Shopify Auto-Validate] ✅ Albarán {picking.name} auto-validado con éxito por ser pedido POS/Fulfilled.")
                        except Exception as e:
                            _logger.warning(f"[Shopify Auto-Validate] No se pudo auto-validar albarán {picking.name}: {e}")

            # 8. Factura Borrador Automática (si se configuró)
            if self.auto_create_draft_invoice:
                invoices = new_order._create_invoices(final=True)
                if invoices and self.order_journal_id and self.order_journal_id.type == 'sale':
                    invoices.write({'journal_id': self.order_journal_id.id})

                # 8.1 Auto-Workflow de Pago (Paso 4 Pro)
                if self.auto_workflow_payment and self.payment_journal_id and financial_status in ['paid', 'partially_paid']:
                    for invoice in invoices.filtered(lambda inv: inv.state == 'draft'):
                        # 1. Publicar la factura interna (sin CFDI, solo control contable)
                        try:
                            invoice.action_post()
                            _logger.info(f"[Shopify Auto-Workflow] ✅ Factura {invoice.name} auto-publicada.")
                            
                            # 2. Registrar el pago
                            if invoice.amount_residual > 0:
                                payment_register = self.env['account.payment.register'].with_context(
                                    active_model='account.move',
                                    active_ids=invoice.ids
                                ).create({
                                    'journal_id': self.payment_journal_id.id,
                                    'amount': invoice.amount_residual,
                                    'payment_date': new_order.date_order,
                                })
                                payment_register.action_create_payments()
                                _logger.info(f"[Shopify Auto-Workflow] 💰 Pago auto-registrado para la factura {invoice.name}.")
                        except Exception as e:
                            _logger.error(f"[Shopify Auto-Workflow] Fallo al auto-publicar/pagar factura para pedido {new_order.name}: {e}")

        # 9. Automatización de Reembolsos y Devoluciones (Fase 2)
        self._process_shopify_refunds_automation_logic(new_order, s_order)

        return new_order

    def _process_shopify_refunds_automation_logic(self, sale_order, s_order):
        """
        Automatización de la Fase 2: Crea automáticamente la Nota de Crédito en Borrador
        y la Transferencia Inversa de Inventario (Devolución) en Odoo ante un reembolso detectado en Shopify.
        Cuenta con un candado de seguridad Sandbox estricto para proteger la base de datos de producción real.
        """
        self.ensure_one()
        if not self.auto_process_refunds:
            return

        # 1. ── CANDADO DE SEGURIDAD EXCLUSIVO PARA PRUEBAS (SANDBOX) ──
        if self.shopify_sandbox_mode:
            has_real_products = False
            for line in sale_order.order_line:
                if not line.display_type and line.product_id:
                    default_code = line.product_id.default_code or ''
                    if 'PRUEBA' not in default_code.upper():
                        has_real_products = True
                        break
            if has_real_products:
                _logger.warning(
                    "[SEGURIDAD SANDBOX] 🛡️ Se detectó un reembolso en Shopify para el pedido real %s. "
                    "Automatización contable/de stock OMITIDA para proteger la base de datos de producción.", 
                    sale_order.name
                )
                return

        # 2. Procesar reembolsos individuales
        processed_ids = (sale_order.shopify_refund_ids_processed or '').split(',')
        processed_ids = [pid.strip() for pid in processed_ids if pid.strip()]
        new_processed_list = list(processed_ids)
        
        refunds = s_order.get('refunds', [])
        if not refunds:
            return

        for r in refunds:
            refund_id = str(r.get('id'))
            if refund_id in processed_ids:
                continue

            _logger.info(
                "[Automatización Reembolso] 🔄 Procesando reembolso de Shopify ID %s para pedido %s",
                refund_id, sale_order.name
            )

            # A. Obtener artículos devueltos y mapear productos
            refund_items = []
            for rit in r.get('refund_line_items', []):
                qty = float(rit.get('quantity') or 0.0)
                if qty <= 0:
                    continue
                
                line_item_id = str(rit.get('line_item_id'))
                sku = ''
                title = ''
                for item in s_order.get('line_items', []):
                    if str(item.get('id')) == line_item_id:
                        sku = item.get('sku')
                        title = item.get('title')
                        break
                
                product = self.env['product.product'].search([('default_code', '=', sku)], limit=1) if sku else False
                if not product and title:
                    product = self.env['product.product'].search([('name', 'ilike', title)], limit=1)
                
                if product:
                    refund_items.append((product, qty))

            if not refund_items:
                _logger.warning("[Automatización Reembolso] No se encontraron productos válidos para el reembolso ID %s", refund_id)
                new_processed_list.append(refund_id)
                continue

            # B. ── AUTOMATIZACIÓN FÍSICA (Stock Picking de Entrada / Devolución) ──
            # Shopify es fuente de verdad: si reporta devolución con restock, crear recepción siempre
            try:
                warehouse = sale_order.warehouse_id
                shopify_loc_id = False
                for rit in r.get('refund_line_items', []):
                    if rit.get('location_id'):
                        shopify_loc_id = str(rit.get('location_id'))
                        break
                if shopify_loc_id:
                    mapping = self.env['shopify.location.mapping'].search([('shopify_location_id', '=', shopify_loc_id)], limit=1)
                    if mapping and mapping.odoo_warehouse_id:
                        warehouse = mapping.odoo_warehouse_id
                        _logger.info(f"[Automatización Reembolso] Usando almacén mapeado '{warehouse.name}' para reingreso de stock.")

                picking_type = warehouse.in_type_id
                location_id = self.env.ref('stock.stock_location_customers', raise_if_not_found=False) or picking_type.default_location_src_id
                location_dest_id = picking_type.default_location_dest_id or warehouse.lot_stock_id

                # Cancelar albaranes de salida pendientes para estos productos (evitar envío doble)
                pending_outgoing = sale_order.picking_ids.filtered(
                    lambda p: p.picking_type_id.code == 'outgoing' and p.state not in ['done', 'cancel']
                )
                if pending_outgoing:
                    returned_product_ids = [p.id for p, q in refund_items]
                    for pick in pending_outgoing:
                        pending_moves = pick.move_ids.filtered(
                            lambda m: m.product_id.id in returned_product_ids and m.state not in ['done', 'cancel']
                        )
                        if pending_moves:
                            pending_moves._action_cancel()
                            _logger.info(
                                "[Automatización Reembolso] 🚫 Movimientos de salida pendientes cancelados en %s para productos devueltos.",
                                pick.name
                            )

                return_picking = self.env['stock.picking'].create({
                    'partner_id': sale_order.partner_id.id,
                    'picking_type_id': picking_type.id,
                    'location_id': location_id.id if location_id else False,
                    'location_dest_id': location_dest_id.id if location_dest_id else False,
                    'origin': f"Devolución Shopify {sale_order.name} | ID Reembolso {refund_id}",
                    'sale_id': sale_order.id,
                    'group_id': sale_order.procurement_group_id.id if sale_order.procurement_group_id else False,
                })

                for product, qty in refund_items:
                    self.env['stock.move'].create({
                        'name': product.name,
                        'product_id': product.id,
                        'product_uom_qty': qty,
                        'product_uom': product.uom_id.id,
                        'picking_id': return_picking.id,
                        'location_id': location_id.id if location_id else False,
                        'location_dest_id': location_dest_id.id if location_dest_id else False,
                    })

                return_picking.action_confirm()
                return_picking.action_assign()
                
                # Auto-validación síncrona inmediata (Modo 1)
                for move in return_picking.move_ids.filtered(lambda m: m.state not in ['done', 'cancel']):
                    move.quantity_done = move.product_uom_qty
                
                try:
                    return_picking.with_context(skip_backorder=True, skip_sms=True).button_validate()
                    _logger.info(
                        "[Automatización Reembolso] ✅ Devolución de stock creada y AUTO-VALIDADA con éxito: %s para pedido %s",
                        return_picking.name, sale_order.name
                    )
                except Exception as e_validate:
                    _logger.warning(
                        "[Automatización Reembolso] Error al auto-validar la recepción %s: %s",
                        return_picking.name, str(e_validate)
                    )

            except Exception as e_stock:
                _logger.error(
                    "[Automatización Reembolso] Error al crear albarán de devolución de stock: %s",
                    str(e_stock)
                )

            # C. ── AUTOMATIZACIÓN CONTABLE (Nota de Crédito en Borrador) ──
            posted_invoices = sale_order.invoice_ids.filtered(lambda m: m.move_type == 'out_invoice' and m.state == 'posted')
            if posted_invoices:
                try:
                    invoice = posted_invoices[0]
                    credit_note = self.env['account.move'].create({
                        'move_type': 'out_refund',
                        'partner_id': sale_order.partner_id.id,
                        'invoice_origin': sale_order.name,
                        'invoice_journal_id': invoice.journal_id.id,
                        'currency_id': invoice.currency_id.id,
                        'ref': f"Reembolso Shopify {sale_order.name} | ID {refund_id}",
                    })

                    for product, qty in refund_items:
                        inv_line = invoice.invoice_line_ids.filtered(lambda l: l.product_id.id == product.id)
                        price_unit = inv_line[0].price_unit if inv_line else product.list_price
                        account_id = inv_line[0].account_id.id if inv_line else (product.property_account_income_id.id or self.env['account.account'].search([('account_type', '=', 'income')], limit=1).id)
                        tax_ids = [(6, 0, inv_line[0].tax_ids.ids)] if inv_line and inv_line[0].tax_ids else []

                        self.env['account.move.line'].create({
                            'move_id': credit_note.id,
                            'product_id': product.id,
                            'quantity': qty,
                            'price_unit': price_unit,
                            'account_id': account_id,
                            'name': product.name,
                            'tax_ids': tax_ids,
                        })

                    _logger.info(
                        "[Automatización Reembolso] ✅ Nota de Crédito en borrador creada con éxito: %s para pedido %s",
                        credit_note.name or 'Borrador', sale_order.name
                    )

                except Exception as e_finance:
                    _logger.error(
                        "[Automatización Reembolso] Error al crear Nota de Crédito contable: %s",
                        str(e_finance)
                    )

            new_processed_list.append(refund_id)

        sale_order.write({
            'shopify_refund_ids_processed': ','.join(new_processed_list) if new_processed_list else ''
        })

    def _run_order_sync_background(self, dbname, config_id, user_id=None):
        from odoo import api, SUPERUSER_ID, registry
        import requests
        import logging
        from datetime import datetime
        _logger = logging.getLogger(__name__)

        with api.Environment.manage():
            new_cr = registry(dbname).cursor()
            try:
                env = api.Environment(new_cr, user_id or SUPERUSER_ID, {})
                config = env['shopify.config'].browse(config_id)
                headers = {'X-Shopify-Access-Token': config.access_token}
                shop_domain = config.shop_url.strip().lower().replace('https://', '').replace('http://', '').split('/')[0]
                
                url = f"https://{shop_domain}/admin/api/2024-01/orders.json?status=any&fulfillment_status=any&limit=250"
                if config.last_order_sync_date:
                    from datetime import timedelta
                    # Restamos 5 minutos para evitar problemas de desfase (time-skew) entre servidores
                    sync_min_date = config.last_order_sync_date - timedelta(minutes=5)
                    url += f"&updated_at_min={sync_min_date.isoformat()}Z"
                
                resp = requests.get(url, headers=headers, timeout=25)
                if resp.status_code != 200:
                    config.write({'sku_simulation_log': f"❌ [Importación Ventas] Error de comunicación con Shopify (HTTP {resp.status_code}). La importación se ha cancelado.", 'is_syncing': False})
                    new_cr.commit()
                    return

                orders = resp.json().get('orders', [])
                total = len(orders)
                config.write({'sku_simulation_log': f"📥 [Importación Ventas] Conexión exitosa. Se encontraron {total} pedidos listos para procesar e importar a Odoo."})
                new_cr.commit()

                for idx, s_order in enumerate(orders, 1):
                    shopify_id = str(s_order.get('id'))
                    order_name = s_order.get('name') or shopify_id
                    try:
                        config.write({
                            'sku_simulation_log': f"⏳ [Importación Ventas] [{idx}/{total}] Procesando e importando pedido: {order_name}...",
                            'sync_progress': (idx / total) * 100
                        })
                        new_cr.commit()

                        # Crear/importar orden de venta de forma centralizada
                        config._import_single_shopify_order(s_order)
                        new_cr.commit()

                    except Exception as e_inner:
                        _logger.error("Error en %s: %s", order_name, str(e_inner))
                        new_cr.rollback()

                config.write({
                    'sku_simulation_log': f"✅ [Importación Ventas] Sincronización manual completada con éxito. Se importaron y procesaron {total} pedidos de Shopify.",
                    'is_syncing': False,
                    'sync_progress': 100.0,
                    'last_order_sync_date': fields.Datetime.now()
                })
                new_cr.commit()

            except Exception as e:
                _logger.error("Error crítico sync: %s", str(e))
                try:
                    config.write({'is_syncing': False})
                    new_cr.commit()
                except: pass
            finally:
                new_cr.close()


    def _run_inventory_sync_background(self, dbname, config_id):
        from odoo import api, SUPERUSER_ID, registry
        import time
        with api.Environment.manage():
            new_cr = registry(dbname).cursor()
            try:
                env = api.Environment(new_cr, SUPERUSER_ID, {})
                config = env['shopify.config'].browse(config_id)
                lines = config.simulation_line_ids.filtered(lambda l: l.is_selected)
                total = len(lines)
                headers = {'X-Shopify-Access-Token': config.access_token}
                shop_domain = config.shop_url.strip().lower().replace('https://', '').replace('http://', '').split('/')[0]
                BATCH_SIZE = 50
                processed = 0
                for i in range(0, total, BATCH_SIZE):
                    batch = lines[i:i+BATCH_SIZE]
                    inv_ids = [l.shopify_inventory_item_id for l in batch if l.shopify_inventory_item_id and l.shopify_inventory_item_id != '0']
                    if not inv_ids: continue
                    url = f"https://{shop_domain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids={','.join(inv_ids)}&limit=250"
                    resp = requests.get(url, headers=headers, timeout=25)
                    if resp.status_code == 200:
                        levels = resp.json().get('inventory_levels', [])
                        stock_map = {}
                        for lvl in levels:
                            item_id, loc_id = str(lvl['inventory_item_id']), str(lvl['location_id'])
                            # Shopify puede devolver None para items no gestionados por inventario
                            stock_map.setdefault(item_id, {})[loc_id] = lvl.get('available') or 0
                        for line in batch:
                            processed += 1
                            item_stock = stock_map.get(str(line.shopify_inventory_item_id), {})
                            product = env['product.product'].search([('default_code', '=', line.old_sku)], limit=1)
                            if product:
                                for mapping in config.location_mapping_ids:
                                    if not mapping.shopify_location_id or not mapping.odoo_location_id: continue
                                    qty = item_stock.get(str(mapping.shopify_location_id)) or 0
                                    env['stock.quant'].with_context(inventory_mode=True).create({
                                        'product_id': product.id, 'location_id': mapping.odoo_location_id.id, 'inventory_quantity': qty
                                    }).action_apply_inventory()
                                # Protección: sum() falla si hay None, convertimos todo a int
                                safe_total = sum(v or 0 for v in item_stock.values())
                                line.write({'shopify_qty_total': safe_total, 'is_selected': False})
                            if processed % 5 == 0:
                                self._safe_write_log(config, f"🔄 Sincronizando Inventario ({processed}/{total})...", (processed/total)*100.0)
                    time.sleep(1.0)
                self._safe_write_log(config, f"✅ SINCRONIZACIÓN MASIVA COMPLETADA: {processed} items.", 100.0)
                config.write({'is_syncing': False})
                new_cr.commit()
            except Exception as e:
                _logger.error("Error en sync masiva: %s", str(e))
                config.write({'is_syncing': False})
                new_cr.commit()
            finally:
                new_cr.close()

    def _run_shopify_sync_background(self, dbname, config_id):
        """Método robusto para sincronizar en segundo plano con feedback en tiempo real."""
        with api.Environment.manage():
            new_cr = registry(dbname).cursor()
            try:
                env = api.Environment(new_cr, 1, {})
                config = env['shopify.config'].browse(config_id)
                config.write({'sync_progress': 0.0})
                new_cr.commit()
                
                lines_to_sync = config.simulation_line_ids.filtered(lambda l: l.is_selected and l.sync_state != 'synced')
                total = len(lines_to_sync)
                if not total:
                    config.is_syncing = False
                    new_cr.commit()
                    return

                # NORMALIZACIÓN DE URL: Vital para evitar el "congelamiento"
                shop_domain = config.shop_url.strip().lower()
                for prefix in ['https://', 'http://']:
                    if shop_domain.startswith(prefix):
                        shop_domain = shop_domain[len(prefix):]
                shop_domain = shop_domain.split('/')[0]
                
                token = config.access_token.strip()
                headers = {
                    "X-Shopify-Access-Token": token,
                    "Content-Type": "application/json"
                }

                success_count = 0
                error_count = 0
                
                for idx, line in enumerate(lines_to_sync, 1):
                    variant_id = line.shopify_variant_id
                    new_sku = line.suggested_sku
                    
                    # Log seguro para feedback
                    self._safe_write_log(config, f"[{idx}/{total}] Sincronizando SKU: {line.product_name} ({new_sku})...", (idx / total) * 100.0)
                    
                    update_url = f"https://{shop_domain}/admin/api/2024-01/variants/{variant_id}.json"
                    payload = {"variant": {"id": variant_id, "sku": new_sku}}
                    
                    try:
                        # Respeto al límite de Shopify (Leve pausa)
                        time.sleep(0.4)
                        
                        resp = requests.put(update_url, json=payload, headers=headers, timeout=12)
                        if resp.status_code == 200:
                            line.write({
                                'sync_state': 'synced', 
                                'is_selected': False,
                                'last_error': False
                            })
                            success_count += 1
                        else:
                            _logger.error("Error Shopify en %s: %s", variant_id, resp.text)
                            line.write({
                                'sync_state': 'error',
                                'last_error': f"Status {resp.status_code}: {resp.text}"
                            })
                            error_count += 1
                            config.write({
                                'sku_simulation_log': config.sku_simulation_log + f"\n ERROR en {new_sku}: {resp.status_code}"
                            })
                    except Exception as e:
                        _logger.error("Excepción en Sync de variante %s: %s", variant_id, str(e))
                        line.write({
                            'sync_state': 'error',
                            'last_error': f"Excepción: {str(e)}"
                        })
                        error_count += 1
                    
                    # Commit después de cada ítem para que el usuario vea el avance real
                    new_cr.commit()

                # Finalizar con éxito
                local_now = fields.Datetime.context_timestamp(config, fields.Datetime.now()).strftime('%Y-%m-%d %H:%M:%S')
                config.sku_simulation_log = f"✅ [Stock] Sincronización masiva de inventario físico finalizada con éxito el {local_now}.\nResultados → Exitosos: {success_count} | Errores/Omitidos: {error_count}."
                
            except Exception as e:
                _logger.error("Error fatal en proceso de fondo: %s", str(e))
                # Intentar loguear el error fatal si el cursor sigue vivo
                try:
                    config.sku_simulation_log = f"❌ [Stock] ERROR FATAL en sincronización masiva de stock: {str(e)}"
                    new_cr.commit()
                except:
                    pass
            finally:
                # El bloque FINALLY garantiza que is_syncing se Resetee siempre
                try:
                    config.is_syncing = False
                    config.sync_progress = 100.0 if not error_count else config.sync_progress
                    new_cr.commit()
                except:
                    pass
                new_cr.close()

    def action_select_all(self):
        self.ensure_one()
        # Solo marcamos los que NO existen en Odoo para no repetir trabajo
        to_select = self.simulation_line_ids.filtered(lambda l: not l.exists_in_odoo)
        to_select.write({'is_selected': True})
        return True

    def action_unselect_all(self):
        self.ensure_one()
        self.simulation_line_ids.write({'is_selected': False})
        return True

    def action_select_by_category(self):
        self.ensure_one()
        if not self.select_category:
            raise UserError(_("Por favor selecciona una categoría primero."))
        
        lines = self.simulation_line_ids.filtered(
            lambda l: l.sku_category_code == self.select_category and l.sync_state != 'synced'
        )
        lines.write({'is_selected': True})
        return True

    def _sync_variants_stock_to_shopify(self, variants):
        """
        Sincroniza el stock de un grupo de variantes (product.product) a Shopify.
        Toma en cuenta el Mapeo de Sucursales (location_mapping_ids).
        Si no hay mapeo, utiliza la ubicación por defecto (shopify_location_id).
        """
        if not variants:
            return 0
            
        shop_domain = (self.shop_url or '').strip().lower()
        if '://' in shop_domain:
            shop_domain = shop_domain.split('://')[1].split('/')[0]
        token = self._ensure_access_token()
        
        url = f"https://{shop_domain}/admin/api/2024-01/inventory_levels/set.json"
        headers = {"X-Shopify-Access-Token": token, "Content-Type": "application/json"}
        
        count = 0
        for variant in variants:
            if not variant.shopify_inventory_item_id:
                continue
            
            if self.location_mapping_ids:
                for mapping in self.location_mapping_ids:
                    if not mapping.shopify_location_id or not mapping.odoo_location_id:
                        continue
                    # Obtener cantidad en la ubicación específica de Odoo
                    qty = int(variant.with_context(location=mapping.odoo_location_id.id).qty_available)
                    payload = {
                        "location_id": mapping.shopify_location_id,
                        "inventory_item_id": variant.shopify_inventory_item_id,
                        "available": qty
                    }
                    try:
                        retries = 3
                        while retries > 0:
                            resp = requests.post(url, headers=headers, json=payload, timeout=15)
                            if resp.status_code == 429:
                                _logger.warning("[Shopify Stock Sync] Rate limit. Esperando 5s...")
                                time.sleep(5)
                                retries -= 1
                                continue
                            if resp.status_code == 200:
                                _logger.info("[Shopify Stock Sync] Variante '%s' sincronizada a Sucursal '%s': %s unidades",
                                             variant.default_code, mapping.shopify_location_name or mapping.shopify_location_id, qty)
                                count += 1
                            else:
                                _logger.warning("[Shopify Stock Sync] Error sincronizando variante '%s' a Sucursal '%s': %s",
                                               variant.default_code, mapping.shopify_location_name or mapping.shopify_location_id, resp.text[:200])
                            break
                    except Exception as e:
                        _logger.error("[Shopify Stock Sync] Excepción sincronizando variante '%s': %s", variant.default_code, str(e))
                    time.sleep(0.5) # Respetar 2 requests/sec
            elif self.shopify_location_id:
                qty = int(variant.qty_available)
                payload = {
                    "location_id": self.shopify_location_id,
                    "inventory_item_id": variant.shopify_inventory_item_id,
                    "available": qty
                }
                try:
                    retries = 3
                    while retries > 0:
                        resp = requests.post(url, headers=headers, json=payload, timeout=15)
                        if resp.status_code == 429:
                            _logger.warning("[Shopify Stock Sync] Rate limit. Esperando 5s...")
                            time.sleep(5)
                            retries -= 1
                            continue
                        if resp.status_code == 200:
                            _logger.info("[Shopify Stock Sync] Variante '%s' sincronizada a Sucursal por defecto de Shopify: %s unidades",
                                         variant.default_code, qty)
                            count += 1
                        else:
                            _logger.warning("[Shopify Stock Sync] Error sincronizando variante '%s' a Sucursal por defecto: %s",
                                           variant.default_code, resp.text[:200])
                        break
                except Exception as e:
                    _logger.error("[Shopify Stock Sync] Excepción sincronizando variante '%s': %s", variant.default_code, str(e))
                time.sleep(0.5) # Respetar 2 requests/sec
                    
        return count

    def action_sync_inventory(self):
        """Paso 1/5: Sincroniza el stock de Odoo a Shopify (Desde el botón de la vista)."""
        self.ensure_one()
        if not self.shopify_location_id and not self.location_mapping_ids:
            raise UserError(_("Primero debes vincular una ubicación de Shopify o configurar el Mapeo de Sucursales."))
            
        # Buscar productos vinculados
        variants = self.env['product.product'].search([('shopify_inventory_item_id', '!=', False)])
        if not variants:
            raise UserError(_("No hay productos vinculados con Shopify. Ejecuta una simulación primero."))

        count = self._sync_variants_stock_to_shopify(variants)
        
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Inventario Sincronizado'),
                'message': _('Se han actualizado %s niveles de inventario en Shopify.') % count,
                'type': 'success',
                'sticky': False,
            }
        }



    # ─────────────────────────────────────────────────────────────────────────
    # AUTOMATIZACIÓN: Métodos llamados por los CRONs de Odoo
    # ─────────────────────────────────────────────────────────────────────────

    LOG_PATH = '/var/lib/odoo/shopify_cron.log'
    LOG_MAX_BYTES = 5 * 1024 * 1024  # 5 MB → rota automáticamente

    def _write_cron_log(self, level, section, message):
        """
        Escribe una entrada en el archivo de log persistente del CRON.
        Formato: [2026-05-14 21:00:00] [PEDIDOS] [INFO] Mensaje
        Rota el archivo si supera LOG_MAX_BYTES (guarda .bak).
        """
        import os
        from datetime import datetime
        try:
            log_path = self.LOG_PATH
            # Asegurarse de que el directorio del log exista
            os.makedirs(os.path.dirname(log_path), exist_ok=True)
            
            # Rotación simple si el archivo crece mucho
            if os.path.exists(log_path) and os.path.getsize(log_path) > self.LOG_MAX_BYTES:
                bak_path = log_path.replace('.log', '.bak')
                if os.path.exists(bak_path):
                    os.remove(bak_path)
                os.rename(log_path, bak_path)

            timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            entry = f"[{timestamp}] [{section.upper():10s}] [{level.upper():8s}] {message}\n"
            with open(log_path, 'a', encoding='utf-8') as f:
                f.write(entry)
        except Exception as log_err:
            _logger.warning("No se pudo escribir en el log de CRON: %s", str(log_err))

    @api.model
    def cron_import_orders(self):
        """
        CRON: Importa pedidos nuevos desde Shopify para todas las configs activas.
        Se ejecuta periódicamente. Solo procesa configuraciones con
        'auto_import_orders' habilitado.
        """
        configs = self.search([('auto_import_orders', '=', True), ('state', '=', 'confirmed')])
        if not configs:
            _logger.info("CRON Pedidos: No hay configs con importación automática habilitada.")
            return

        for config in configs:
            _logger.info("CRON Pedidos: Iniciando para '%s'", config.name)
            try:
                import requests as req
                from datetime import datetime

                # ⚠️ IMPORTANTE: Capturamos el tiempo ANTES de llamar a Shopify.
                sync_start = fields.Datetime.now()

                shop_domain = config.shop_url.strip().lower().replace('https://', '').replace('http://', '').split('/')[0]

                # ✅ Renovar token automáticamente si está próximo a expirar (24h Shopify)
                token = config._ensure_access_token()
                headers = {'X-Shopify-Access-Token': token}
                url = f"https://{shop_domain}/admin/api/2024-01/orders.json?status=any&fulfillment_status=any&limit=250"
                if config.last_order_sync_date:
                    from datetime import timedelta
                    # Restamos 5 minutos para evitar desfases de reloj entre Odoo y Shopify
                    sync_min_date = config.last_order_sync_date - timedelta(minutes=5)
                    url += f"&updated_at_min={sync_min_date.isoformat()}Z"

                self._write_cron_log('info', 'PEDIDOS', f"=== INICIO CRON | Tienda: {config.name} | Desde: {config.last_order_sync_date or 'sin filtro'} ===")

                resp = req.get(url, headers=headers, timeout=25)
                if resp.status_code == 401:
                    self._write_cron_log('error', 'PEDIDOS', f"TOKEN EXPIRADO o INVALIDO para '{config.name}'. HTTP 401. Verifica que el Client ID y Client Secret estén configurados.")
                    _logger.error("CRON Pedidos: Token inválido para '%s'", config.name)
                    continue
                if resp.status_code != 200:
                    self._write_cron_log('error', 'PEDIDOS', f"Error Shopify HTTP {resp.status_code} para '{config.name}': {resp.text[:200]}")
                    _logger.error("CRON Pedidos: Error Shopify %s: %s", resp.status_code, resp.text)
                    continue

                orders = resp.json().get('orders', [])
                total = len(orders)
                self._write_cron_log('info', 'PEDIDOS', f"{total} pedido(s) encontrado(s) para '{config.name}'")
                _logger.info("CRON Pedidos: %s pedidos encontrados para '%s'", total, config.name)

                for s_order in orders:
                    shopify_id = str(s_order.get('id'))
                    order_name = s_order.get('name', shopify_id)

                    try:
                        # Procesamiento unificado de la orden
                        config._import_single_shopify_order(s_order)
                        self.env.cr.commit()
                        self._write_cron_log('ok', 'PEDIDOS', f"Pedido CREADO/PROCESADO: {order_name}")
                        _logger.info("CRON Pedidos: Creado/Procesado %s", order_name)

                    except Exception as e_inner:
                        self._write_cron_log('error', 'PEDIDOS', f"Error procesando pedido {order_name}: {str(e_inner)}")
                        _logger.error("CRON Pedidos: Error en %s: %s", order_name, str(e_inner))
                        self.env.cr.rollback()

                local_sync_start = fields.Datetime.context_timestamp(config, sync_start).strftime('%Y-%m-%d %H:%M:%S')
                config.write({
                    'last_order_sync_date': sync_start,
                    'sku_simulation_log': f"✅ [CRON Ventas] Importación automática de pedidos finalizada con éxito: se procesaron {total} pedidos nuevos desde Shopify. La próxima ejecución buscará pedidos desde {local_sync_start}",
                })
                self.env.cr.commit()
                self._write_cron_log('info', 'PEDIDOS', f"=== FIN CRON OK | {total} pedido(s) procesado(s) | Próxima búsqueda desde {local_sync_start} ===")
                _logger.info("CRON Pedidos: Finalizado para '%s'.", config.name)

            except Exception as e:
                self._write_cron_log('critical', 'PEDIDOS', f"ERROR CRÍTICO en '{config.name}': {str(e)}")
                _logger.error("CRON Pedidos: Error crítico en '%s': %s", config.name, str(e))

    @api.model
    def cron_sync_inventory_to_shopify(self):
        """
        CRON: Envía el stock físico real desde Odoo hacia Shopify para mantener sincronizado
        el catálogo y prevenir ventas sin existencias. Odoo es la Fuente de Verdad.
        """
        configs = self.search([('state', '=', 'confirmed')])
        if not configs:
            _logger.info("CRON Inventario: No hay configs activas.")
            return

        for config in configs:
            if not config.location_mapping_ids and not config.shopify_location_id:
                _logger.warning("CRON Inventario: '%s' no tiene mapeo de ubicaciones ni sucursal por defecto. Saltando.", config.name)
                continue

            _logger.info("CRON Inventario: Iniciando envío de stock Odoo ➔ Shopify para '%s'", config.name)
            try:
                # Buscar variantes de productos ligadas con Shopify (con shopify_inventory_item_id)
                variants = self.env['product.product'].search([('shopify_inventory_item_id', '!=', False)])
                if not variants:
                    config._write_cron_log('warning', 'INVENTARIO', f"No hay variantes ligadas con Shopify en '{config.name}'. Ejecuta una simulación o importación primero.")
                    _logger.warning("CRON Inventario: No hay variantes ligadas con Shopify en '%s'.", config.name)
                    continue

                config._write_cron_log('info', 'INVENTARIO', f"=== INICIO CRON ENVÍO STOCK | Tienda: {config.name} | {len(variants)} variantes ===")
                count = config._sync_variants_stock_to_shopify(variants)
                config._write_cron_log('info', 'INVENTARIO', f"=== FIN CRON ENVÍO STOCK OK | {count} niveles actualizados en Shopify ===")
                _logger.info("CRON Inventario: Finalizado con éxito para '%s'. Se actualizaron %s niveles.", config.name, count)

            except Exception as e:
                config._write_cron_log('critical', 'INVENTARIO', f"ERROR CRÍTICO en envío de stock para '{config.name}': {str(e)}")
                _logger.error("CRON Inventario: Error crítico en '%s': %s", config.name, str(e))

    @api.model
    def cron_sync_inventory_from_shopify(self):
        """
        [REDIRECCIÓN DE SEGURIDAD OPERATIVA]
        Este cron anteriormente importaba stock de Shopify a Odoo.
        Para respetar la regla operativa 'Odoo es la Fuente de Verdad', este método
        ahora redirige automáticamente la llamada hacia el cron_sync_inventory_to_shopify.
        Garantiza compatibilidad absoluta con ejecuciones programadas viejas en base de datos.
        """
        _logger.info("CRON Inventario: Redirigiendo de forma segura a cron_sync_inventory_to_shopify()")
        return self.cron_sync_inventory_to_shopify()



    def _get_csv_sku_map(self):
        """Lee el CSV cargado (o el archivo físico actualizado) y genera mapas híbridos"""
        sku_map_h = {}
        sku_map_t = {}
        
        csv_path = "/var/www/vhosts/tesscorp.com.mx/hazard.tesscorp.com.mx/SKU_SHOPIFY_ACTUALIZADO.csv"
        csv_data_raw = None
        
        # PRIORIDAD 1: El archivo físico actualizado que acaba de subir el cliente
        if os.path.exists(csv_path):
            _logger.info("Leyendo CSV directamente desde el disco: %s", csv_path)
            with open(csv_path, 'rb') as f:
                csv_data_raw = f.read()
        # PRIORIDAD 2: El archivo subido por la interfaz de Odoo
        elif self.csv_file:
            csv_data_raw = base64.b64decode(self.csv_file)

        if not csv_data_raw:
            _logger.warning("No se ha subido ningún archivo CSV ni se encontró SKU_SHOPIFY_ACTUALIZADO.csv")
            return sku_map_h, sku_map_t

        # DECODIFICACIÓN ROBUSTA: Intenta UTF-8 y cae en Latin-1 si falla
        csv_text = ""
        for encoding in ['utf-8-sig', 'latin-1', 'iso-8859-1', 'cp1252']:
            try:
                csv_text = csv_data_raw.decode(encoding)
                _logger.info("CSV decodificado exitosamente con: %s", encoding)
                break
            except UnicodeDecodeError:
                continue
        
        if not csv_text:
            csv_text = csv_data_raw.decode('utf-8-sig', errors='replace')
            _logger.warning("Fallo total de decodificación, usando 'replace' como último recurso.")

        try:
            f = io.StringIO(csv_text)
            reader = csv.DictReader(f)
            
            for row in reader:
                handle = row.get('Handle')
                title = row.get('Title')
                if not handle and not title: continue
                
                # Normalización estricta
                handle_clean = handle.strip().lower() if handle else ''
                title_clean = title.strip().lower() if title else ''
                
                opt1 = (row.get('Option1 Value') or '').strip()
                opt2 = (row.get('Option2 Value') or '').strip()
                opt3 = (row.get('Option3 Value') or '').strip()
                sku = (row.get('Variant SKU') or '').strip()
                
                if sku:
                    key_h = (handle_clean, opt1, opt2, opt3)
                    key_t = (title_clean, opt1, opt2, opt3)
                    
                    if handle_clean: sku_map_h[key_h] = sku
                    if title_clean: sku_map_t[key_t] = sku
                        
        except Exception as e:
            _logger.error("Error al procesar el archivo CSV subido: %s", str(e))
        
        return sku_map_h, sku_map_t

    @api.model
    def cron_refresh_tokens(self):
        """
        CRON: Renueva preventivamente los tokens de todas las configuraciones
        activas de Shopify. Se ejecuta cada 20 horas para garantizar que los
        tokens (duración 24h según Shopify) nunca expiren de forma inesperada,
        incluso si el usuario no abre Odoo en todo el día.
        """
        configs = self.search([('state', '=', 'confirmed'), ('api_key', '!=', False), ('api_secret', '!=', False)])
        if not configs:
            _logger.info("CRON Tokens: No hay configs activas con credenciales configuradas.")
            return

        for config in configs:
            try:
                now = fields.Datetime.now()
                # Renovar si el token expira en menos de 6 horas (margen amplio)
                safety_margin = datetime.timedelta(hours=6)
                needs_refresh = (
                    not config.access_token
                    or not config.access_token_expiry
                    or config.access_token_expiry <= (now + safety_margin)
                )
                if needs_refresh:
                    _logger.info("CRON Tokens: Renovando token para '%s' (expiry=%s)", config.name, config.access_token_expiry)
                    config.action_get_access_token()
                    self.env.cr.commit()
                    self._write_cron_log('ok', 'TOKEN', f"Token renovado para '{config.name}'. Nuevo expiry: {config.access_token_expiry}")
                    _logger.info("CRON Tokens: Token renovado para '%s'. Nuevo expiry: %s", config.name, config.access_token_expiry)
                else:
                    _logger.info("CRON Tokens: Token de '%s' aún válido hasta %s. Sin acción.", config.name, config.access_token_expiry)
            except Exception as e:
                self._write_cron_log('error', 'TOKEN', f"Error renovando token para '{config.name}': {str(e)}")
                _logger.error("CRON Tokens: Error para '%s': %s", config.name, str(e))

    @api.model
    def cron_auto_download_images(self):
        """
        CRON Permanente: Se ejecuta periódicamente (ej: diario o cada 2 horas)
        para descargar imágenes de Shopify para productos nuevos sin imagen en Odoo.
        A diferencia del descargador por lotes masivo, este cron NUNCA se auto-desactiva.
        """
        configs = self.search([('state', '=', 'confirmed'), ('access_token', '!=', False)], limit=1)
        if not configs:
            return

        config = configs[0]
        try:
            token = config._ensure_access_token()
            if not token:
                return

            shop_domain = config.shop_url.strip().lower()
            if '://' in shop_domain:
                shop_domain = shop_domain.split('://')[1].split('/')[0]
            headers = {"X-Shopify-Access-Token": token}

            # Buscar variantes o plantillas sin imagen en Odoo
            pending = self.env['product.template'].search([
                ('shopify_id', '!=', False),
                ('image_1920', '=', False),
                ('shopify_sync_error', 'not in', ['no_shopify_image']),
            ], limit=30)

            if not pending:
                return

            import requests
            import base64

            count_downloaded = 0
            for tmpl in pending:
                shopify_id = tmpl.shopify_id
                url = f"https://{shop_domain}/admin/api/2024-01/products/{shopify_id}.json"
                try:
                    resp = requests.get(url, headers=headers, timeout=15)
                    if resp.status_code == 200:
                        product_data = resp.json().get('product', {})
                        images = product_data.get('images', [])
                        if images:
                            img_url = images[0].get('src')
                            if img_url:
                                img_resp = requests.get(img_url, timeout=15)
                                if img_resp.status_code == 200:
                                    tmpl.write({
                                        'image_1920': base64.b64encode(img_resp.content),
                                        'shopify_sync_error': False
                                    })
                                    count_downloaded += 1
                                    self.env.cr.commit()
                                    continue
                        tmpl.write({'shopify_sync_error': 'no_shopify_image'})
                        self.env.cr.commit()
                except Exception as ex_single:
                    _logger.warning("Error descargando imagen para plantilla %s: %s", tmpl.id, str(ex_single))
            
            if count_downloaded > 0:
                _logger.info("CRON Imágenes Auto: Descargadas %s imágenes nuevas desde Shopify.", count_downloaded)
        except Exception as e:
            _logger.error("Error en CRON Auto Descarga Imágenes: %s", str(e))

    def write(self, vals):

        res = super(ShopifyConfig, self).write(vals)
        # Sincronizar el Cron físico de Odoo de Pedidos si cambian campos clave
        if 'auto_import_orders' in vals or 'order_sync_interval' in vals:
            for record in self:
                cron_orders = self.env.ref('hazard_shopify.ir_cron_shopify_import_orders', raise_if_not_found=False)
                if cron_orders:
                    cron_orders.sudo().write({
                        'active': record.auto_import_orders,
                        'interval_number': int(record.order_sync_interval or '30'),
                        'interval_type': 'minutes',
                    })
                
        # Sincronizar el Cron físico de Odoo de Inventario si cambian campos clave
        if 'cron_sync_inventory_active' in vals or 'cron_sync_inventory_interval' in vals:
            for record in self:
                cron_inventory = self.env.ref('hazard_shopify.ir_cron_shopify_sync_inventory', raise_if_not_found=False)
                if cron_inventory:
                    cron_inventory.sudo().write({
                        'active': record.cron_sync_inventory_active,
                        'interval_number': int(record.cron_sync_inventory_interval or '720'),
                        'interval_type': 'minutes',
                    })

        # Sincronizar el cron de auto-descarga de imágenes
        if 'auto_import_images' in vals or 'image_sync_interval' in vals:
            for record in self:
                cron_images = self.env.ref('hazard_shopify.ir_cron_shopify_auto_image_sync', raise_if_not_found=False)
                if cron_images:
                    cron_images.sudo().write({
                        'active': record.auto_import_images,
                        'interval_number': int(record.image_sync_interval or '2'),
                        'interval_type': 'hours',
                    })
        return res

    def action_fetch_shopify_costs(self):
        self.ensure_one()
        self.cost_is_fetching = True
        self.env.cr.commit()

        import requests
        import logging
        _logger = logging.getLogger(__name__)

        try:
            # 1. Clean existing pending lines
            self.cost_line_ids.filtered(lambda l: l.state in ['pending', 'error']).unlink()

            # 2. Get products linked to Shopify
            products = self.env['product.product'].search([
                ('type', '=', 'product'),
                ('shopify_inventory_item_id', '!=', False)
            ])
            
            if not products:
                raise UserError("No se encontraron productos con shopify_inventory_item_id para sincronizar costos.")

            shop_url = self.shop_url
            if not shop_url.startswith('https://'):
                shop_url = f"https://{shop_url}"
            graphql_url = f"{shop_url}/admin/api/2024-01/graphql.json"
            headers = {
                "X-Shopify-Access-Token": self.access_token,
                "Content-Type": "application/json"
            }

            batch_size = 100
            products_list = list(products)
            for i in range(0, len(products_list), batch_size):
                batch = products_list[i:i+batch_size]
                
                gid_mapping = {}
                for p in batch:
                    inv_id = p.shopify_inventory_item_id
                    if inv_id:
                        clean_id = ''.join(filter(str.isdigit, str(inv_id)))
                        if clean_id:
                            gid = f"gid://shopify/InventoryItem/{clean_id}"
                            gid_mapping[gid] = p
                
                if not gid_mapping:
                    continue
                    
                gids = list(gid_mapping.keys())
                
                query = """
                query GetInventoryCosts($ids: [ID!]!) {
                  nodes(ids: $ids) {
                    ... on InventoryItem {
                      id
                      unitCost {
                        amount
                      }
                    }
                  }
                }
                """
                
                variables = {"ids": gids}
                payload = {
                    "query": query,
                    "variables": variables
                }
                
                resp = requests.post(graphql_url, headers=headers, json=payload, timeout=20)
                if resp.status_code == 200:
                    data = resp.json().get('data', {})
                    nodes = data.get('nodes', [])
                    for node in nodes:
                        if not node:
                            continue
                        gid = node.get('id')
                        cost_data = node.get('unitCost')
                        
                        if gid in gid_mapping and cost_data:
                            amount = float(cost_data.get('amount') or 0.0)
                            p = gid_mapping[gid]
                            
                            self.env['shopify.cost.line'].create({
                                'config_id': self.id,
                                'product_id': p.id,
                                'current_cost': p.standard_price,
                                'new_cost': amount,
                                'shopify_inventory_item_id': str(p.shopify_inventory_item_id),
                                'state': 'pending'
                            })
                else:
                    _logger.error(f"Error GraphQL Costos: {resp.text}")
                    
        except Exception as e:
            raise UserError(f"Error al consultar costos: {str(e)}")
        finally:
            self.cost_is_fetching = False
            self.env.cr.commit()

    def action_apply_shopify_costs(self):
        self.ensure_one()
        self.cost_is_pushing = True
        self.env.cr.commit()

        try:
            pending_lines = self.cost_line_ids.filtered(lambda l: l.state == 'pending')
            if not pending_lines:
                raise UserError("No hay líneas pendientes para aplicar.")
                
            for line in pending_lines:
                try:
                    line.product_id.standard_price = line.new_cost
                    line.state = 'done'
                except Exception as e:
                    line.state = 'error'
                    line.error_message = str(e)
                    
        except Exception as e:
            raise UserError(f"Error al aplicar costos: {str(e)}")
        finally:
            self.cost_is_pushing = False
            self.env.cr.commit()

class ShopifyLocationMapping(models.Model):
    _name = 'shopify.location.mapping'
    _description = 'Mapeo de Ubicaciones Shopify'

    config_id = fields.Many2one('shopify.config', string='Configuración', ondelete='cascade')
    shopify_location_id = fields.Char(string='ID Shopify', required=True)
    shopify_location_name = fields.Char(string='Nombre en Shopify')
    odoo_location_id = fields.Many2one('stock.location', string='Ubicación Odoo', domain=[('usage', '=', 'internal')])
    odoo_warehouse_id = fields.Many2one('stock.warehouse', string='Almacén Odoo')

    @api.onchange('odoo_warehouse_id')
    def _onchange_odoo_warehouse_id(self):
        if self.odoo_warehouse_id:
            self.odoo_location_id = self.odoo_warehouse_id.lot_stock_id

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    shopify_order_id = fields.Char(string='ID Pedido Shopify', copy=False, index=True)
    shopify_source = fields.Char(string='Origen Shopify', help="web, pos, etc.")
