// ==========================================================================
// CLIENT ENGINE - MP MANAGER
// ==========================================================================

let branchesData = [];
let activeBranchId = "global"; // Default: Consolidado Global
let activePipelineId = "";
let currentContactsPage = 1;
let contactsSearchQuery = "";
let activeTab = "tab-dashboard";
let contactsWithoutOppFilter = false;
let isSyncingGlobally = false;
let syncProgressInterval = null;
let activeScriptsCategory = "all";
let scriptsSearchQuery = "";

// ==========================================================================
// APP MODE — Toggle global Dry-Run / Live
// ==========================================================================
// Modo universal de la plataforma. Cuando el usuario lo cambia, TODAS las
// acciones mutantes lo respetan: el frontend inyecta el header X-Dry-Run en
// cada fetch mutante (usar mutateFetch), y el backend chequea ese header para
// ramificar entre simulación y escritura real en Bucéfalo.
//
// Default: dry-run (más seguro). Persiste en localStorage entre sesiones.
// Cualquier cambio dispara el evento "app-mode-changed" para que componentes
// suelten su UI específica (ej. el botón de Scripts cambia su label).

const APP_MODE_STORAGE_KEY = "mp_manager_app_mode";

window.AppMode = (function () {
    function read() {
        try {
            const raw = localStorage.getItem(APP_MODE_STORAGE_KEY);
            if (raw === "live") return "live";
            return "dryrun";
        } catch (_) {
            return "dryrun";
        }
    }
    function write(mode) {
        try { localStorage.setItem(APP_MODE_STORAGE_KEY, mode); } catch (_) {}
    }
    let current = read();
    return {
        get isDryRun() { return current === "dryrun"; },
        get isLive() { return current === "live"; },
        get value() { return current; },
        set(mode) {
            const next = mode === "live" ? "live" : "dryrun";
            if (next === current) return;
            current = next;
            write(next);
            window.dispatchEvent(new CustomEvent("app-mode-changed", { detail: { mode: next } }));
        },
        toggle() { this.set(current === "dryrun" ? "live" : "dryrun"); },
    };
})();

/**
 * fetch wrapper para llamadas MUTANTES (POST/PUT/PATCH/DELETE).
 * Inyecta el header X-Dry-Run según el modo global.
 *
 * Reglas:
 *   - SIEMPRE usar mutateFetch para acciones que escriben en Bucéfalo o que
 *     llaman scripts mutadores (no GETs ni endpoints read-only).
 *   - El backend respeta el header. Si tu endpoint aún no lo respeta, sigue
 *     ejecutando real — el header es informativo. Hay que actualizar el
 *     endpoint para que ramifique.
 */
async function mutateFetch(url, options = {}) {
    const headers = new Headers(options.headers || {});
    headers.set("X-Dry-Run", window.AppMode.isDryRun ? "1" : "0");
    headers.set("X-App-Mode", window.AppMode.value);
    return fetch(url, { ...options, headers });
}

/**
 * Inicializa el toggle global del header. Sincroniza estado, dispara eventos,
 * actualiza estilos. Se llama una vez en DOMContentLoaded.
 */
function initGlobalModeToggle() {
    const wrapper = document.getElementById("global-mode-toggle");
    const checkbox = document.getElementById("global-mode-checkbox");
    const valueLabel = document.getElementById("global-mode-value");
    if (!wrapper || !checkbox || !valueLabel) return;

    function syncUI() {
        const isDryRun = window.AppMode.isDryRun;
        checkbox.checked = !isDryRun; // checked = live (peligroso)
        wrapper.classList.toggle("is-dryrun", isDryRun);
        wrapper.classList.toggle("is-live", !isDryRun);
        valueLabel.textContent = isDryRun ? "Simulación" : "Live (Real)";
        wrapper.title = isDryRun
            ? "Modo Simulación (Dry-Run): las acciones se simulan sin tocar Bucéfalo. Cambia a Live para escribir real."
            : "MODO LIVE: las acciones modifican Bucéfalo de verdad. Cambia a Simulación para volver a un modo seguro.";
        const icon = wrapper.querySelector(".global-mode-icon i");
        if (icon) {
            icon.className = isDryRun ? "fa-solid fa-shield-halved" : "fa-solid fa-bolt";
        }
    }

    // Click en el wrapper o en el checkbox alterna el modo.
    function handleToggle(e) {
        if (e && e.target && (e.target.closest("input[type=checkbox]") || e.target.closest(".toggle-switch"))) {
            // El checkbox/switch maneja su propio click; nosotros respondemos al change.
            return;
        }
        e?.stopPropagation();
        confirmAndToggle();
    }

    async function confirmAndToggle() {
        const goingToLive = window.AppMode.isDryRun;
        if (goingToLive) {
            const ok = await appConfirm({
                title: "Activar modo LIVE",
                severity: "danger",
                message: `
                    Vas a salir del modo simulación. A partir de ahora, todas las acciones
                    de la plataforma <strong>escribirán en Bucéfalo de verdad</strong> (eliminar
                    contactos, crear oportunidades, sincronizar Marca↔Sucursal, etc.).<br><br>
                    Si tu intención es probar primero, mantén el modo en <strong>Simulación</strong>.
                `,
                confirmText: "Sí, activar Live",
                cancelText: "Quedarme en Simulación",
            });
            if (!ok) return;
        }
        window.AppMode.toggle();
    }

    wrapper.addEventListener("click", handleToggle);
    checkbox.addEventListener("change", () => {
        // checked = live. Si pasó de dryrun → live, confirmar.
        if (checkbox.checked && window.AppMode.isDryRun) {
            // revertir UI temporalmente; confirmAndToggle decide.
            checkbox.checked = false;
            confirmAndToggle();
        } else if (!checkbox.checked && window.AppMode.isLive) {
            window.AppMode.set("dryrun");
        }
    });

    window.addEventListener("app-mode-changed", syncUI);
    syncUI();
}

// --- INICIALIZACIÓN ---

document.addEventListener("DOMContentLoaded", () => {
    initTabNavigation();
    initEventListeners();
    initGlobalModeToggle();
    initUniversalDryRunToggle(); // Legacy: el toggle del tab Scripts queda como espejo del global.
    loadBranches().then(() => {
        selectBranch(activeBranchId);
        checkGlobalSyncProgress(); // Verificar si ya hay sync corriendo al cargar
    });
});

// --- SISTEMA DE TABS ---

function initTabNavigation() {
    const tabs = document.querySelectorAll(".tab-link");
    updateTabLayoutMode();
    tabs.forEach(tab => {
        tab.addEventListener("click", () => {
            tabs.forEach(t => t.classList.remove("active"));
            tab.classList.add("active");
            
            const targetTab = tab.getAttribute("data-tab");
            activeTab = targetTab;
            
            document.querySelectorAll(".tab-section").forEach(sec => {
                sec.classList.remove("active");
            });
            document.getElementById(targetTab).classList.add("active");
            updateTabLayoutMode();
            
            // Cargar datos según la pestaña activa
            refreshActiveView();
        });
    });
}

function updateTabLayoutMode() {
    const wrapper = document.querySelector(".tab-content-wrapper");
    if (!wrapper) return;
    wrapper.classList.toggle("scripts-mode", activeTab === "tab-scripts");
}

function refreshActiveView() {
    if (activeTab === "tab-dashboard") {
        loadDashboardMetrics();
    } else if (activeTab === "tab-comparativa") {
        loadComparativaMarcaSucursales();
    } else if (activeTab === "tab-contacts") {
        currentContactsPage = 1;
        loadContactsTable();
    } else if (activeTab === "tab-opportunities") {
        loadPipelinesAndBoard();
    } else if (activeTab === "tab-scripts") {
        loadScriptsCatalog();
    } else if (activeTab === "tab-workflows") {
        loadWorkflowsTable();
    }
}

// --- EVENT LISTENERS ---

function initEventListeners() {
    // Info de la sucursal activa en el Header
    const activeInfo = document.querySelector(".active-info");
    if (activeInfo) {
        activeInfo.removeAttribute("title");
    }

    // Buscador de sucursales en Sidebar
    const searchInput = document.getElementById("branch-search");
    searchInput.addEventListener("input", (e) => {
        filterBranches(e.target.value);
    });

    // Buscador en la tabla Master Global
    const globalMasterSearch = document.getElementById("global-master-search");
    if (globalMasterSearch) {
        globalMasterSearch.addEventListener("input", (e) => {
            renderGlobalMasterTable(e.target.value);
        });
    }

    // Sincronización Global
    document.getElementById("btn-sync-global").addEventListener("click", triggerGlobalSync);
    const syncMenuBtn = document.getElementById("btn-sync-menu");
    const syncMenu = document.getElementById("sync-menu");
    const syncMetadataBtn = document.getElementById("btn-sync-metadata");
    if (syncMenuBtn && syncMenu) {
        syncMenuBtn.addEventListener("click", (event) => {
            event.stopPropagation();
            syncMenu.classList.toggle("hidden");
        });
        document.addEventListener("click", () => syncMenu.classList.add("hidden"));
    }
    if (syncMetadataBtn) {
        syncMetadataBtn.addEventListener("click", (event) => {
            event.stopPropagation();
            syncMenu.classList.add("hidden");
            triggerMetadataSync();
        });
    }

    // Sincronización Sucursal Activa
    document.getElementById("btn-sync-active").addEventListener("click", triggerActiveBranchSync);

    // Buscador en Tabla de Contactos
    const contactSearch = document.getElementById("contact-table-search");
    contactSearch.addEventListener("input", (e) => {
        contactsSearchQuery = e.target.value;
        currentContactsPage = 1;
        // debounce simple
        clearTimeout(contactSearch.timeout);
        contactSearch.timeout = setTimeout(() => {
            loadContactsTable();
        }, 300);
    });

    // Toggle "Solo sin oportunidad" en la pestaña Contactos
    const withoutOppBtn = document.getElementById("btn-filter-without-opp");
    if (withoutOppBtn) {
        withoutOppBtn.addEventListener("click", () => {
            if (activeBranchId === "global") return;
            contactsWithoutOppFilter = !contactsWithoutOppFilter;
            currentContactsPage = 1;
            loadContactsTable();
        });
    }

    // Botón bulk: crear oportunidades para todos los contactos sin opp.
    const bulkCreateBtn = document.getElementById("btn-bulk-create-opps");
    if (bulkCreateBtn) {
        bulkCreateBtn.addEventListener("click", () => bulkCreateOpportunities());
    }

    // Paginación de Contactos
    document.getElementById("btn-page-prev").addEventListener("click", () => {
        if (currentContactsPage > 1) {
            currentContactsPage--;
            loadContactsTable();
        }
    });
    document.getElementById("btn-page-next").addEventListener("click", () => {
        currentContactsPage++;
        loadContactsTable();
    });

    // Selector de Pipeline en Kanban
    document.getElementById("pipeline-select").addEventListener("change", (e) => {
        activePipelineId = e.target.value;
        loadKanbanBoard();
    });

    // Pestañas internas de la terminal (Salida / Errores)
    initTerminalTabs();

    // Limpiar terminal — ahora va por el LiveTerminal (vacía buffer + DOM).
    document.getElementById("btn-clear-terminal").addEventListener("click", () => {
        const term = ensureLiveTerminal();
        term.reset();
        const screen = document.getElementById("terminal-screen");
        // Repintar placeholder informativo.
        const placeholder = document.createElement("div");
        placeholder.className = "terminal-placeholder";
        placeholder.innerHTML = '<i class="fa-solid fa-terminal"></i><p>Terminal Limpia</p><span>Selecciona un script y presiona ejecutar para ver resultados aquí.</span>';
        screen.appendChild(placeholder);
        // Ocultar bar de run (no hay run activo)
        const runBar = document.getElementById("terminal-run-bar");
        if (runBar) runBar.classList.add("hidden");
        const dlBtn = document.getElementById("btn-download-log");
        if (dlBtn) dlBtn.classList.add("hidden");
        clearTerminalErrors();
        setTerminalState("idle", "Listo");
        if (window.activeExecutingCard) {
            setCardState(window.activeExecutingCard, "idle", `<i class="fa-solid fa-play"></i> Ejecutar`);
            window.activeExecutingCard = null;
        }
    });

    // Badge "Volver al final" — hace scroll al fondo y re-engancha el auto-scroll.
    const resumeScrollBtn = document.getElementById("terminal-resume-scroll");
    if (resumeScrollBtn) {
        resumeScrollBtn.addEventListener("click", () => {
            const term = ensureLiveTerminal();
            term.scrollToBottom();
            resumeScrollBtn.classList.add("hidden");
        });
    }

    // Botón "Descargar log completo" — apunta al endpoint persistente del run activo.
    const downloadLogBtn = document.getElementById("btn-download-log");
    if (downloadLogBtn) {
        downloadLogBtn.addEventListener("click", () => {
            const taskId = downloadLogBtn.getAttribute("data-task-id");
            if (!taskId) {
                showToast("No hay un run activo del cual descargar el log.", "info");
                return;
            }
            window.location.href = `/api/scripts/logs/${encodeURIComponent(taskId)}`;
        });
    }

    // Copiar terminal (respeta la pestaña activa)
    document.getElementById("btn-copy-terminal").addEventListener("click", () => {
        const activeTab = getActiveTerminalTab();
        const textToCopy = activeTab === "errors"
            ? buildErrorsCopyText()
            : buildOutputCopyText();

        if (!textToCopy) {
            showToast(activeTab === "errors" ? "No hay errores para copiar." : "No hay logs para copiar.", "info");
            return;
        }
        
        function fallbackCopyText(text) {
            const textArea = document.createElement("textarea");
            textArea.value = text;
            textArea.style.position = "fixed";
            textArea.style.left = "-9999px";
            document.body.appendChild(textArea);
            textArea.focus();
            textArea.select();
            try {
                const successful = document.execCommand('copy');
                if (successful) {
                    showToast("Logs copiados al portapapeles.", "success");
                } else {
                    showToast("No se pudo copiar los logs.", "error");
                }
            } catch (err) {
                showToast("Error al copiar logs.", "error");
            }
            document.body.removeChild(textArea);
        }

        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(textToCopy).then(() => {
                showToast("Logs copiados al portapapeles.", "success");
            }).catch(err => {
                fallbackCopyText(textToCopy);
            });
        } else {
            fallbackCopyText(textToCopy);
        }
    });

    // Filtros de Workflows
    const workflowSearch = document.getElementById("workflow-table-search");
    if (workflowSearch) {
        workflowSearch.addEventListener("input", () => {
            renderWorkflowsTable();
        });
    }

    const workflowBranchSelect = document.getElementById("workflow-branch-select");
    if (workflowBranchSelect) {
        workflowBranchSelect.addEventListener("change", () => {
            renderWorkflowsTable();
        });
    }

    const workflowStatusSelect = document.getElementById("workflow-status-select");
    if (workflowStatusSelect) {
        workflowStatusSelect.addEventListener("change", () => {
            renderWorkflowsTable();
        });
    }

    const workflowDateField = document.getElementById("workflow-date-field");
    if (workflowDateField) {
        workflowDateField.addEventListener("change", () => renderWorkflowsTable());
    }
    const workflowDateAfter = document.getElementById("workflow-date-after");
    if (workflowDateAfter) {
        workflowDateAfter.addEventListener("change", () => renderWorkflowsTable());
    }
    const workflowDateBefore = document.getElementById("workflow-date-before");
    if (workflowDateBefore) {
        workflowDateBefore.addEventListener("change", () => renderWorkflowsTable());
    }
    const btnClearDates = document.getElementById("btn-workflow-clear-dates");
    if (btnClearDates) {
        btnClearDates.addEventListener("click", () => {
            if (workflowDateAfter) workflowDateAfter.value = "";
            if (workflowDateBefore) workflowDateBefore.value = "";
            renderWorkflowsTable();
        });
    }

    const btnSyncWorkflows = document.getElementById("btn-sync-workflows");
    if (btnSyncWorkflows) {
        btnSyncWorkflows.addEventListener("click", triggerWorkflowsSync);
    }

    const btnExportWorkflows = document.getElementById("btn-export-workflows");
    if (btnExportWorkflows) {
        btnExportWorkflows.addEventListener("click", exportWorkflowsExcel);
    }

    const btnRefreshSession = document.getElementById("btn-refresh-session");
    if (btnRefreshSession) {
        btnRefreshSession.addEventListener("click", refreshBucefaloSession);
    }

    const btnBulkDraft = document.getElementById("btn-bulk-draft");
    if (btnBulkDraft) btnBulkDraft.addEventListener("click", () => triggerBulkToggle("draft"));

    const btnBulkPublish = document.getElementById("btn-bulk-publish");
    if (btnBulkPublish) btnBulkPublish.addEventListener("click", () => triggerBulkToggle("publish"));

    const btnBulkDelete = document.getElementById("btn-bulk-delete");
    if (btnBulkDelete) btnBulkDelete.addEventListener("click", () => triggerBulkToggle("delete"));

    const btnBulkScanAnomalies = document.getElementById("btn-bulk-scan-anomalies");
    if (btnBulkScanAnomalies) btnBulkScanAnomalies.addEventListener("click", triggerBulkScanAnomalies);

    const btnBulkAnomaliesToggle = document.getElementById("btn-bulk-anomalies-toggle");
    if (btnBulkAnomaliesToggle) btnBulkAnomaliesToggle.addEventListener("click", bulkToggleAnomaliesList);

    const btnBulkCancel = document.getElementById("btn-bulk-cancel");
    if (btnBulkCancel) btnBulkCancel.addEventListener("click", cancelBulkScan);

    const btnBulkClear = document.getElementById("btn-bulk-clear");
    if (btnBulkClear) btnBulkClear.addEventListener("click", clearWorkflowSelection);

    const btnBulkCloseProgress = document.getElementById("btn-bulk-close-progress");
    if (btnBulkCloseProgress) btnBulkCloseProgress.addEventListener("click", bulkExitProgressMode);

    const btnBulkRetryPending = document.getElementById("btn-bulk-retry-pending");
    if (btnBulkRetryPending) btnBulkRetryPending.addEventListener("click", retryBulkPending);

    const selectAll = document.getElementById("workflows-select-all");
    if (selectAll) selectAll.addEventListener("change", toggleWorkflowsSelectAll);

    const scriptSearchInput = document.getElementById("script-search-input");
    if (scriptSearchInput) {
        scriptSearchInput.addEventListener("input", (e) => {
            scriptsSearchQuery = e.target.value.trim().toLowerCase();
            renderScriptsByFilter(activeScriptsCategory);
        });
    }
}

function initUniversalDryRunToggle() {
    // El toggle de la terminal de Scripts ya NO es un control independiente:
    // es un espejo del estado global (window.AppMode) y reflejo visual.
    // Toda interacción real ocurre desde el toggle del header global.
    const checkbox = document.getElementById("universal-dryrun-checkbox");
    const wrapper = document.getElementById("universal-dryrun-wrapper");
    if (!checkbox || !wrapper) return;

    // Reemplaza la etiqueta para dejar claro que es un reflejo, no un control aparte.
    const label = wrapper.querySelector(".toggle-label");
    if (label) label.textContent = "Modo (global)";

    function sync() {
        const isDryRun = window.AppMode.isDryRun;
        checkbox.checked = isDryRun; // mantenemos su semántica original: checked = dry-run
        wrapper.classList.toggle("is-active", isDryRun);
        wrapper.classList.toggle("is-danger", !isDryRun);
        wrapper.title = isDryRun
            ? "Modo Simulación (controlado desde el toggle global del header)."
            : "MODO LIVE — los scripts ejecutan cambios reales en Bucéfalo.";
    }

    // El checkbox interno no edita el estado: redirige al toggle global.
    checkbox.addEventListener("change", (e) => {
        // Cualquier cambio aquí redirige al toggle global (que pide confirmación si va a live).
        e.preventDefault();
        const desiredDryRun = checkbox.checked;
        if (desiredDryRun && window.AppMode.isLive) {
            window.AppMode.set("dryrun");
        } else if (!desiredDryRun && window.AppMode.isDryRun) {
            // Disparar la confirmación del global haciendo click en el header.
            document.getElementById("global-mode-toggle")?.click();
            // restaurar visual hasta que el global decida.
            sync();
        }
    });

    window.addEventListener("app-mode-changed", sync);
    sync();
}

// --- NOTIFICACIONES TOAST ---

function showToast(message, type = "info") {
    const container = document.getElementById("toast-container");
    const toast = document.createElement("div");
    toast.className = `toast ${type}`;
    
    let icon = "info-circle";
    if (type === "success") icon = "check-circle";
    if (type === "error") icon = "triangle-exclamation";
    
    toast.innerHTML = `
        <i class="fa-solid fa-${icon}"></i>
        <span>${message}</span>
    `;
    
    container.appendChild(toast);
    
    // Auto-eliminar
    setTimeout(() => {
        toast.style.animation = "slideIn 0.3s ease reverse";
        setTimeout(() => {
            toast.remove();
        }, 300);
    }, 4000);
}

// --- MODALES CUSTOM (appConfirm / appPrompt) ---
// Reemplazan los pop-ups nativos del browser (confirm/prompt/alert).
// Promise-based: `const ok = await appConfirm({...});`

function _createAppModal({ title, severity = 'info', bodyHTML, footerHTML }) {
    const iconByService = {
        danger: 'triangle-exclamation',
        warning: 'circle-exclamation',
        info: 'circle-info',
    };
    const icon = iconByService[severity] || 'circle-info';
    const backdrop = document.createElement('div');
    backdrop.className = 'modal-backdrop';
    backdrop.innerHTML = `
        <div class="modal-card severity-${severity}">
            <div class="modal-header">
                <h3><i class="fa-solid fa-${icon}"></i> ${title}</h3>
                <button class="btn-close-modal" data-act="cancel" type="button">&times;</button>
            </div>
            <div class="modal-body">${bodyHTML}</div>
            <div class="modal-footer">${footerHTML}</div>
        </div>
    `;
    document.body.appendChild(backdrop);
    return backdrop;
}

function appConfirm({ title, message, confirmText = 'Confirmar', cancelText = 'Cancelar', severity = 'info' }) {
    return new Promise((resolve) => {
        const confirmBtnClass = severity === 'danger' ? 'btn-danger' : 'btn-primary';
        const modal = _createAppModal({
            title, severity,
            bodyHTML: `<p class="app-modal-message">${message}</p>`,
            footerHTML: `
                <button class="btn btn-secondary" data-act="cancel" type="button">${cancelText}</button>
                <button class="btn ${confirmBtnClass}" data-act="ok" type="button">${confirmText}</button>
            `,
        });
        const close = (val) => {
            modal.remove();
            document.removeEventListener('keydown', onKey);
            resolve(val);
        };
        const onKey = (e) => {
            if (e.key === 'Escape') close(false);
            else if (e.key === 'Enter') close(true);
        };
        modal.addEventListener('click', (e) => {
            if (e.target === modal) return close(false);
            const act = e.target.closest('[data-act]')?.dataset.act;
            if (act === 'ok') close(true);
            else if (act === 'cancel') close(false);
        });
        document.addEventListener('keydown', onKey);
        // Foco en el botón de confirmar para que Enter dispare confirma.
        setTimeout(() => modal.querySelector('[data-act="ok"]')?.focus(), 50);
    });
}

/**
 * Modal de selección: el usuario elige un ítem de una lista (radio buttons).
 * Devuelve Promise con el índice del seleccionado (0-based), o null si canceló.
 *
 * options: [{ label: string, sublabel?: string }]
 */
function appSelectFromList({ title, message, options, confirmText = 'Aceptar', cancelText = 'Cancelar', severity = 'info' }) {
    return new Promise((resolve) => {
        const optionsHTML = options.map((opt, i) => `
            <label class="app-modal-radio-row">
                <input type="radio" name="app-modal-select" value="${i}" ${i === 0 ? 'checked' : ''}>
                <div class="app-modal-radio-text">
                    <div class="app-modal-radio-label">${opt.label}</div>
                    ${opt.sublabel ? `<div class="app-modal-radio-sublabel">${opt.sublabel}</div>` : ''}
                </div>
            </label>
        `).join('');
        const modal = _createAppModal({
            title, severity,
            bodyHTML: `
                <p class="app-modal-message">${message}</p>
                <div class="app-modal-radio-list">${optionsHTML}</div>
            `,
            footerHTML: `
                <button class="btn btn-secondary" data-act="cancel" type="button">${cancelText}</button>
                <button class="btn btn-primary" data-act="ok" type="button">${confirmText}</button>
            `,
        });
        const close = (val) => {
            modal.remove();
            document.removeEventListener('keydown', onKey);
            resolve(val);
        };
        const onKey = (e) => {
            if (e.key === 'Escape') close(null);
            else if (e.key === 'Enter') {
                const sel = modal.querySelector('input[name="app-modal-select"]:checked');
                close(sel ? parseInt(sel.value, 10) : null);
            }
        };
        modal.addEventListener('click', (e) => {
            if (e.target === modal) return close(null);
            const act = e.target.closest('[data-act]')?.dataset.act;
            if (act === 'ok') {
                const sel = modal.querySelector('input[name="app-modal-select"]:checked');
                close(sel ? parseInt(sel.value, 10) : null);
            } else if (act === 'cancel') {
                close(null);
            }
        });
        document.addEventListener('keydown', onKey);
        setTimeout(() => modal.querySelector('[data-act="ok"]')?.focus(), 50);
    });
}

function appPrompt({ title, message, expectedAnswer, placeholder = '', severity = 'danger' }) {
    return new Promise((resolve) => {
        const modal = _createAppModal({
            title, severity,
            bodyHTML: `
                <p class="app-modal-message">${message}</p>
                <input type="text" class="app-modal-prompt-input" placeholder="${placeholder}" autocomplete="off" spellcheck="false">
            `,
            footerHTML: `
                <button class="btn btn-secondary" data-act="cancel" type="button">Cancelar</button>
                <button class="btn btn-danger" data-act="ok" type="button" disabled>Confirmar</button>
            `,
        });
        const input = modal.querySelector('.app-modal-prompt-input');
        const okBtn = modal.querySelector('[data-act="ok"]');
        input.addEventListener('input', () => {
            okBtn.disabled = input.value !== expectedAnswer;
        });
        setTimeout(() => input.focus(), 50);

        const close = (val) => {
            modal.remove();
            document.removeEventListener('keydown', onKey);
            resolve(val);
        };
        const onKey = (e) => {
            if (e.key === 'Escape') close(false);
            else if (e.key === 'Enter' && !okBtn.disabled) close(true);
        };
        modal.addEventListener('click', (e) => {
            if (e.target === modal) return close(false);
            const act = e.target.closest('[data-act]')?.dataset.act;
            if (act === 'ok' && !okBtn.disabled) close(true);
            else if (act === 'cancel') close(false);
        });
        document.addEventListener('keydown', onKey);
    });
}

/**
 * Modal de progreso para tareas largas (renovar sesión, etc.).
 * Devuelve un controlador con métodos para actualizar el estado.
 *
 * @returns {{
 *   setStatus: (title: string, detail?: string, logLine?: string) => void,
 *   setSuccess: (title: string, detail?: string, autoCloseMs?: number) => void,
 *   setError:   (title: string, detail?: string) => void,
 *   close: () => void,
 * }}
 */
function appProgressModal({ title, initialDetail = '', severity = 'info' }) {
    const modal = _createAppModal({
        title, severity,
        bodyHTML: `
            <div class="app-progress-body">
                <div class="app-progress-icon spinning" data-icon></div>
                <div class="app-progress-title" data-title>${initialDetail || 'Iniciando…'}</div>
                <div class="app-progress-detail" data-detail></div>
                <div class="app-progress-log" data-log></div>
            </div>
        `,
        footerHTML: `<button class="btn btn-secondary" data-act="close" type="button" style="display:none;">Cerrar</button>`,
    });
    const iconEl   = modal.querySelector('[data-icon]');
    const titleEl  = modal.querySelector('[data-title]');
    const detailEl = modal.querySelector('[data-detail]');
    const logEl    = modal.querySelector('[data-log]');
    const closeBtn = modal.querySelector('[data-act="close"]');

    let closed = false;
    let onCloseHook = null;
    const close = () => {
        if (closed) return;
        closed = true;
        modal.remove();
        document.removeEventListener('keydown', onKey);
        if (typeof onCloseHook === 'function') onCloseHook();
    };
    const onKey = (e) => {
        // Solo permitir cerrar con ESC si el proceso terminó (botón visible).
        if (e.key === 'Escape' && closeBtn.style.display !== 'none') close();
    };
    document.addEventListener('keydown', onKey);
    modal.addEventListener('click', (e) => {
        if (e.target.closest('[data-act="close"]')) close();
        // Click en backdrop: solo cerrar si proceso terminó.
        if (e.target === modal && closeBtn.style.display !== 'none') close();
    });
    // Mientras corre, el botón × del header tampoco cierra arbitrariamente.
    modal.querySelector('.btn-close-modal')?.addEventListener('click', (e) => {
        if (closeBtn.style.display === 'none') {
            e.stopPropagation();
        }
    });

    return {
        setStatus(title, detail, logLine) {
            if (closed) return;
            iconEl.className = 'app-progress-icon spinning';
            iconEl.innerHTML = '';
            titleEl.textContent = title;
            if (detail !== undefined) detailEl.textContent = detail;
            if (logLine !== undefined) logEl.textContent = logLine;
        },
        setSuccess(title, detail, autoCloseMs = 2500) {
            if (closed) return;
            iconEl.className = 'app-progress-icon success';
            iconEl.innerHTML = '<i class="fa-solid fa-check"></i>';
            titleEl.textContent = title;
            detailEl.textContent = detail || '';
            logEl.textContent = '';
            closeBtn.style.display = '';
            if (autoCloseMs > 0) setTimeout(close, autoCloseMs);
        },
        setError(title, detail) {
            if (closed) return;
            iconEl.className = 'app-progress-icon failure';
            iconEl.innerHTML = '<i class="fa-solid fa-xmark"></i>';
            titleEl.textContent = title;
            detailEl.textContent = detail || '';
            logEl.textContent = '';
            closeBtn.style.display = '';
        },
        close,
        onClose(fn) { onCloseHook = fn; },
    };
}

// --- SELECTOR Y FILTRADO DE SUCURSALES ---

async function loadBranches() {
    try {
        const response = await fetch("/api/branches");
        branchesData = await response.json();
        renderBranchesSidebar();
    } catch (e) {
        showToast("Error cargando el catálogo de sucursales.", "error");
    }
}

function renderBranchesSidebar() {
    const globalContainer = document.getElementById("global-overview-container");
    const brandContainer = document.getElementById("brand-account-container");
    const branchesContainer = document.getElementById("branches-container");
    
    if (globalContainer) globalContainer.innerHTML = "";
    brandContainer.innerHTML = "";
    branchesContainer.innerHTML = "";
    
    const brandAcc = branchesData.find(b => b.type === "brand");
    const branches = branchesData.filter(b => b.type === "branch");
    
    // Renderizar Consolidado Global
    if (globalContainer) {
        let totalContacts = 0;
        let totalOpps = 0;
        
        branches.forEach(b => {
            totalContacts += b.metrics ? b.metrics.contacts_count : 0;
            totalOpps += b.metrics ? b.metrics.opps_count : 0;
        });
        
        const div = document.createElement("div");
        div.className = `branch-item ${activeBranchId === "global" ? "active" : ""}`;
        div.setAttribute("data-id", "global");
        div.innerHTML = `
            <div style="display: flex; align-items: center; gap: 10px;">
                <i class="fa-solid fa-earth-americas" style="font-size: 16px; color: ${activeBranchId === "global" ? "#fff" : "var(--color-primary)"};"></i>
                <div>
                    <div class="branch-item-name" style="font-weight: 700;">Consolidado Global</div>
                    <div class="branch-item-meta">Todas las sucursales</div>
                </div>
            </div>
            <div class="branch-item-meta text-right">
                <div><i class="fa-solid fa-user"></i> ${totalContacts}</div>
                <div><i class="fa-solid fa-briefcase"></i> ${totalOpps}</div>
            </div>
        `;
        div.addEventListener("click", () => selectBranch("global"));
        globalContainer.appendChild(div);
    }
    
    // Renderizar Marca
    if (brandAcc) {
        const item = createBranchDOMItem(brandAcc);
        brandContainer.appendChild(item);
    }
    
    // Renderizar Sucursales
    branches.forEach(b => {
        const item = createBranchDOMItem(b);
        branchesContainer.appendChild(item);
    });
}

function createBranchDOMItem(b) {
    const div = document.createElement("div");
    div.className = `branch-item ${b.location_id === activeBranchId ? "active" : ""}`;
    div.setAttribute("data-id", b.location_id);
    
    const contactsCount = b.metrics ? b.metrics.contacts_count : 0;
    const oppsCount = b.metrics ? b.metrics.opps_count : 0;
    
    // Quitar prefijo numérico para sidebar
    let displayName = b.nombre;
    if (b.type === "branch") {
        displayName = b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, "");
    }
    
    div.innerHTML = `
        <div>
            <div class="branch-item-name">${displayName}</div>
            <div class="branch-item-meta">${b.location_id.substring(0, 8)}...</div>
        </div>
        <div class="branch-item-meta text-right">
            <div><i class="fa-solid fa-user"></i> ${contactsCount}</div>
            <div><i class="fa-solid fa-briefcase"></i> ${oppsCount}</div>
        </div>
    `;
    
    div.addEventListener("click", () => selectBranch(b.location_id));
    return div;
}

function selectBranch(locationId) {
    // Al cambiar de sucursal se resetea el filtro "sin oportunidad" porque
    // es un estado contextual al branch que estabas viendo. Las navegaciones
    // que quieran preservarlo (p.ej. openBranchWithoutOppView) lo re-activan
    // explícitamente DESPUÉS de llamar a selectBranch.
    if (activeBranchId !== locationId) {
        contactsWithoutOppFilter = false;
    }
    activeBranchId = locationId;

    // Actualizar clase activa en Sidebar
    document.querySelectorAll(".branch-item").forEach(item => {
        item.classList.remove("active");
        if (item.getAttribute("data-id") === locationId) {
            item.classList.add("active");
        }
    });
    
    // Obtener datos de la sucursal seleccionada
    if (locationId === "global") {
        document.getElementById("active-branch-name").innerText = "Consolidado Global";
        document.getElementById("active-branch-owner").innerHTML = `
            <i class="fa-solid fa-earth-americas"></i> Resumen de las 49 sucursales | <code>Vista Consolidada</code>
        `;
        document.getElementById("btn-sync-active").classList.add("hidden");
    } else {
        const b = branchesData.find(x => x.location_id === locationId);
        if (b) {
            const ghlUrl = `https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(b.location_id)}/dashboard`;
            document.getElementById("active-branch-name").innerHTML =
                `<a href="${ghlUrl}" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir sucursal en Bucéfalo">${b.nombre}</a>`;
            document.getElementById("active-branch-owner").innerHTML = `
                <i class="fa-solid fa-building"></i> ${b.company_owner} | Location: <code>${b.location_id}</code>
            `;
        }
        document.getElementById("btn-sync-active").classList.remove("hidden");
    }
    updateActiveInfoSummary();
    
    // Reset pipeline activo
    activePipelineId = "";
    
    // Refrescar la pestaña actual
    refreshActiveView();
}

function filterBranches(query) {
    const q = query.toLowerCase().strip ? query.toLowerCase().strip() : query.toLowerCase();
    
    document.querySelectorAll("#branches-container .branch-item").forEach(item => {
        const id = item.getAttribute("data-id");
        const b = branchesData.find(x => x.location_id === id);
        if (b) {
            const name = b.nombre.toLowerCase();
            if (name.includes(q) || id.toLowerCase().includes(q)) {
                item.style.display = "flex";
            } else {
                item.style.display = "none";
            }
        }
    });
}

// --- 1. DASHBOARD CONTROLLER ---

function loadDashboardMetrics() {
    const detailsGrid = document.getElementById("dashboard-details-grid");
    const globalDetails = document.getElementById("global-dashboard-details");
    
    if (activeBranchId === "global") {
        // Toggle view containers
        if (detailsGrid) detailsGrid.classList.add("hidden");
        if (globalDetails) globalDetails.classList.remove("hidden");
        
        // Calcular métricas globales consolidadas
        let totalContacts = 0;
        let wonValue = 0;
        let wonCount = 0;
        let openValue = 0;
        let openCount = 0;
        let lostValue = 0;
        let lostCount = 0;

        branchesData.forEach(b => {
            if (b.metrics) {
                totalContacts += b.metrics.contacts_count || 0;
                
                const won = b.metrics.opps_by_status?.won;
                wonValue += won ? (won.value || 0) : 0;
                wonCount += won ? (won.count || 0) : 0;
                
                const open = b.metrics.opps_by_status?.open;
                openValue += open ? (open.value || 0) : 0;
                openCount += open ? (open.count || 0) : 0;
                
                const lost = b.metrics.opps_by_status?.lost;
                lostValue += lost ? (lost.value || 0) : 0;
                lostCount += lost ? (lost.count || 0) : 0;
            }
        });
        
        // Rellenar métricas superiores
        document.getElementById("dash-contacts-count").innerText = totalContacts;
        document.getElementById("dash-won-value").innerText = formatCurrency(wonValue);
        document.getElementById("dash-won-count").innerText = `${wonCount} ganadas`;
        
        document.getElementById("dash-open-value").innerText = formatCurrency(openValue);
        document.getElementById("dash-open-count").innerText = `${openCount} abiertas`;
        
        document.getElementById("dash-lost-value").innerText = formatCurrency(lostValue);
        document.getElementById("dash-lost-count").innerText = `${lostCount} perdidas`;
        
        // Renderizar la tabla Master Global
        renderGlobalMasterTable(document.getElementById("global-master-search")?.value || "");
        
    } else {
        // Vista de sucursal individual
        if (detailsGrid) detailsGrid.classList.remove("hidden");
        if (globalDetails) globalDetails.classList.add("hidden");
        
        const b = branchesData.find(x => x.location_id === activeBranchId);
        if (!b || !b.metrics) return;
        
        const m = b.metrics;
        
        // Rellenar métricas superiores
        document.getElementById("dash-contacts-count").innerText = m.contacts_count;
        
        const won = m.opps_by_status.won;
        document.getElementById("dash-won-value").innerText = formatCurrency(won.value);
        document.getElementById("dash-won-count").innerText = `${won.count} ganadas`;
        
        const open = m.opps_by_status.open;
        document.getElementById("dash-open-value").innerText = formatCurrency(open.value);
        document.getElementById("dash-open-count").innerText = `${open.count} abiertas`;
        
        const lost = m.opps_by_status.lost;
        document.getElementById("dash-lost-value").innerText = formatCurrency(lost.value);
        document.getElementById("dash-lost-count").innerText = `${lost.count} perdidas`;

        // Renderizar Distribución de Estados
        const distContainer = document.getElementById("status-distribution-container");
        if (distContainer) {
            distContainer.innerHTML = "";
            const totalOpps = m.opps_count || 1; // Evitar división por cero
            
            const states = [
                { label: "Ganadas (Won)", class: "won", count: won.count },
                { label: "Abiertas (Open)", class: "open", count: open.count },
                { label: "Perdidas (Lost)", class: "lost", count: lost.count },
                { label: "Abandonadas (Abandoned)", class: "abandoned", count: m.opps_by_status.abandoned.count }
            ];
            
            states.forEach(s => {
                const pct = ((s.count / totalOpps) * 100).toFixed(1);
                const bar = document.createElement("div");
                bar.className = "dist-bar-wrapper";
                bar.innerHTML = `
                    <div class="dist-bar-label">
                        <span>${s.label}</span>
                        <span>${s.count} (${pct}%)</span>
                    </div>
                    <div class="dist-bar-bg">
                        <div class="dist-bar-fill ${s.class}" style="width: ${pct}%"></div>
                    </div>
                `;
                distContainer.appendChild(bar);
            });
        }
        loadPipelineOverview();
    }
}

function updateActiveInfoSummary(pipelines = null) {
    const details = document.getElementById("active-branch-details");
    if (!details) return;

    if (activeBranchId === "global") {
        const branches = branchesData.filter(b => b.type === "branch");
        const totalContacts = branches.reduce((sum, b) => sum + (b.metrics?.contacts_count || 0), 0);
        const totalOpps = branches.reduce((sum, b) => sum + (b.metrics?.opps_count || 0), 0);
        const totalPipelines = branches.reduce((sum, b) => sum + (b.metrics?.pipelines_count || 0), 0);
        details.innerHTML = `
            <span><i class="fa-solid fa-store"></i> ${branches.length} sucursales</span>
            <span><i class="fa-solid fa-user-group"></i> ${totalContacts} contactos</span>
            <span><i class="fa-solid fa-briefcase"></i> ${totalOpps} oportunidades</span>
            <span><i class="fa-solid fa-diagram-project"></i> ${totalPipelines} pipelines</span>
        `;
        return;
    }

    const branch = branchesData.find(x => x.location_id === activeBranchId);
    if (!branch) {
        details.innerHTML = `<span>Cuenta no encontrada</span>`;
        return;
    }

    const metrics = branch.metrics || {};
    const pipelineCount = pipelines ? pipelines.length : (metrics.pipelines_count || 0);
    const stagesCount = pipelines ? pipelines.reduce((sum, pipe) => sum + (pipe.stages?.length || 0), 0) : null;
    const stagesLabel = stagesCount === null ? "etapas" : `${stagesCount} etapas`;
    details.innerHTML = `
        <span><i class="fa-solid fa-user-group"></i> ${metrics.contacts_count || 0} contactos</span>
        <span><i class="fa-solid fa-briefcase"></i> ${metrics.opps_count || 0} oportunidades</span>
        <span><i class="fa-solid fa-diagram-project"></i> ${pipelineCount} pipelines</span>
        <span><i class="fa-solid fa-list-check"></i> ${stagesLabel}</span>
    `;
}

async function loadPipelineOverview() {
    const container = document.getElementById("pipeline-overview-container");
    if (!container) return;

    if (activeBranchId === "global") {
        container.innerHTML = `<p class="text-secondary">El overview de pipelines se muestra al seleccionar una sucursal o cuenta específica.</p>`;
        return;
    }

    const requestedLocationId = activeBranchId;
    container.innerHTML = `<p class="text-secondary"><i class="fa-solid fa-spinner fa-spin"></i> Cargando pipelines y etapas...</p>`;

    try {
        const [pipelinesResp, oppsResp] = await Promise.all([
            fetch(`/api/pipelines/${requestedLocationId}`),
            fetch(`/api/opportunities/${requestedLocationId}`)
        ]);
        if (!pipelinesResp.ok) throw new Error("Error cargando pipelines");
        const data = await pipelinesResp.json();
        if (requestedLocationId !== activeBranchId) return;
        const pipelines = data.pipelines || [];
        updateActiveInfoSummary(pipelines);

        // Conteo de oportunidades por pipeline y por etapa
        const oppsByPipeline = {};
        const oppsByStage = {};
        let totalOpps = 0;
        if (oppsResp.ok) {
            const oppsData = await oppsResp.json();
            const oppsList = oppsData.opportunities || [];
            totalOpps = oppsList.length;
            for (const o of oppsList) {
                if (o.pipeline_id) {
                    oppsByPipeline[o.pipeline_id] = (oppsByPipeline[o.pipeline_id] || 0) + 1;
                }
                if (o.pipeline_stage_id) {
                    oppsByStage[o.pipeline_stage_id] = (oppsByStage[o.pipeline_stage_id] || 0) + 1;
                }
            }
        }

        if (pipelines.length === 0) {
            container.innerHTML = `<p class="text-secondary">No hay pipelines registrados para esta cuenta. Sincroniza la cuenta para actualizar el cache local.</p>`;
            return;
        }

        const stagesTotal = pipelines.reduce((sum, pipe) => sum + (pipe.stages?.length || 0), 0);
        const pipelineItems = pipelines.map(pipe => {
            const stages = pipe.stages || [];
            const pipeOppCount = oppsByPipeline[pipe.id] || 0;
            const stagesHtml = stages.length > 0
                ? stages.map((stage, index) => {
                    const stageOppCount = oppsByStage[stage.id] || 0;
                    return `
                    <div class="pipeline-stage-row">
                        <span class="stage-order">${index + 1}</span>
                        <span class="stage-name">${stage.name || "Etapa sin nombre"}</span>
                        <span class="stage-opp-count" title="Oportunidades en esta etapa">${stageOppCount}</span>
                    </div>
                `;
                }).join("")
                : `<span class="text-secondary">Sin etapas registradas.</span>`;
            return `
                <div class="pipeline-overview-item">
                    <div class="pipeline-overview-item-header">
                        <div>
                            <span class="pipeline-label">Pipeline</span>
                            <strong><a href="https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(requestedLocationId)}/opportunities/pipeline/${encodeURIComponent(pipe.id || "")}?tab=stages" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir pipeline ${pipe.name || ""} en Bucéfalo">${pipe.name || "Pipeline sin nombre"}</a> <code class="pipeline-id" title="ID del pipeline">${pipe.id || "—"}</code></strong>
                        </div>
                        <span title="Oportunidades en este pipeline / etapas configuradas"><i class="fa-solid fa-briefcase" style="margin-right: 4px;"></i>${pipeOppCount} opps · ${stages.length} etapas</span>
                    </div>
                    <div class="pipeline-stages-title">Etapas</div>
                    <div class="pipeline-stages-list">${stagesHtml}</div>
                </div>
            `;
        }).join("");

        // Botón "Migrar al pipeline más reciente": visible solo si hay >1 pipeline.
        // Dispara dedupe_branch_pipelines vía endpoint, respeta toggle global Dry-Run/Live.
        const dedupeButtonHTML = pipelines.length > 1
            ? `<div class="pipeline-overview-actions">
                  <button id="btn-dedupe-pipelines" class="btn btn-primary btn-sm"
                          data-location-id="${requestedLocationId}"
                          title="Migra todas las oportunidades del pipeline más antiguo al más reciente (por dateUpdated). Mapea etapas por nombre cuando coinciden o por posición. El plan se previsualiza antes de aplicar.">
                      <i class="fa-solid fa-shuffle"></i> Migrar al pipeline más reciente
                  </button>
              </div>`
            : "";

        container.innerHTML = `
            <div class="pipeline-overview-summary">
                <div><strong>${pipelines.length}</strong><span>pipelines disponibles</span></div>
                <div><strong>${totalOpps}</strong><span>oportunidades totales</span></div>
                <div><strong>${stagesTotal}</strong><span>etapas configuradas</span></div>
            </div>
            ${dedupeButtonHTML}
            ${pipelineItems}
        `;

        const dedupeBtn = document.getElementById("btn-dedupe-pipelines");
        if (dedupeBtn) {
            dedupeBtn.addEventListener("click", () => dedupePipelinesForBranch(dedupeBtn));
        }
    } catch (e) {
        console.error("Error cargando overview de pipelines", e);
        container.innerHTML = `<p class="text-danger">No se pudo cargar el overview de pipelines.</p>`;
    }
}

/**
 * Dispara la migración de oportunidades del pipeline más antiguo al más reciente
 * en la sucursal indicada. Flujo:
 *   1) Pide preview al backend en modo dry-run (sin escribir).
 *   2) Muestra el plan al usuario en modal interno con appConfirm.
 *   3) Si aprueba Y el toggle global está en Live: aplica con escritura real.
 *      Si el toggle global está en Dry-Run: solo muestra el plan, no aplica.
 *   4) Toast con resumen + refresh del overview.
 */
async function dedupePipelinesForBranch(btn) {
    const locationId = btn.dataset.locationId;
    if (!locationId) return;
    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Calculando plan…`;

    try {
        // 1. Preview en dry-run, sin importar el toggle global (preview es siempre seguro).
        const previewRes = await fetch(`/api/pipelines/${encodeURIComponent(locationId)}/dedupe`, {
            method: "POST",
            headers: { "Content-Type": "application/json", "X-Dry-Run": "1" },
            body: "{}",
        });
        const preview = await previewRes.json();
        if (!previewRes.ok) {
            const detail = (preview && preview.detail) || "No se pudo calcular el plan.";
            showToast(typeof detail === "string" ? detail : JSON.stringify(detail), "error");
            return;
        }

        const plans = preview.plans || [];
        if (plans.length === 0) {
            await appConfirm({
                title: "Sin pipelines duplicados",
                severity: "info",
                message: preview.message || "Los pipelines de esta sucursal no tienen nombres normalizados coincidentes. Si quieres migrar entre dos pipelines específicos con nombres distintos, hay que usar el script CLI con <code>--from-pipeline</code> y <code>--to-pipeline</code>.",
                confirmText: "Entendido",
                cancelText: "Cerrar",
            });
            return;
        }

        // 2. Renderizar el plan en modal.
        const fmtDate = (iso) => iso ? iso.replace("T", " ").substring(0, 16) + " UTC" : "—";
        const plansHTML = plans.map((p, idx) => {
            const mappingHtml = (p.mapping || []).map(m =>
                `<li><code>${escapeHtmlAttr(m.from_stage)}</code> → <code>${escapeHtmlAttr(m.to_stage)}</code></li>`
            ).join("");
            // Breakdown por flujo (puede no venir si el backend es viejo).
            const plain = p.plain_move_count ?? p.opportunities_count;
            const mergeObs = p.merge_obsolete_won_count ?? 0;
            const mergeOff = p.merge_official_won_count ?? 0;
            const breakdownHtml = (mergeObs + mergeOff > 0)
                ? `
                    <div style="margin-top: 8px; padding: 8px 10px; background: rgba(255, 159, 64, 0.06); border: 1px solid rgba(255, 159, 64, 0.2); border-radius: 6px;">
                        <div style="font-weight: 600; color: #ffb05a; margin-bottom: 4px;">Duplicados detectados — resolución automática</div>
                        <div style="font-size: 11px; line-height: 1.5;">
                            <div>• <strong>${plain}</strong> opps se moverían directamente (sin duplicado).</div>
                            <div>• <strong>${mergeObs}</strong> opps el <strong>obsoleto gana</strong> (etapa más avanzada): sus custom fields se copian a la oficial, la oficial se actualiza al stage avanzado, y la obsoleta se elimina.</div>
                            <div>• <strong>${mergeOff}</strong> opps la <strong>oficial gana</strong> (ya estaba más avanzada): la oficial queda intacta, la obsoleta simplemente se elimina.</div>
                        </div>
                    </div>
                  `
                : "";
            return `
                <div style="margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--border-color, rgba(255,255,255,0.08)); border-radius: 8px;">
                    <div style="font-weight: 600; margin-bottom: 6px;">Plan ${idx + 1}</div>
                    <div style="font-size: 12px; color: var(--text-secondary);">
                        <div><strong>Origen (obsoleto):</strong> ${escapeHtmlAttr(p.obsolete.name)} <code style="font-size:10px;">${escapeHtmlAttr(p.obsolete.id)}</code></div>
                        <div style="opacity: 0.7;">actualizado ${fmtDate(p.obsolete.date_updated)}</div>
                        <div style="margin-top: 6px;"><strong>Destino (oficial):</strong> ${escapeHtmlAttr(p.official.name)} <code style="font-size:10px;">${escapeHtmlAttr(p.official.id)}</code></div>
                        <div style="opacity: 0.7;">actualizado ${fmtDate(p.official.date_updated)}</div>
                        <div style="margin-top: 6px;"><strong>Mapeo de etapas:</strong> ${escapeHtmlAttr(p.mapping_kind || "n/a")}</div>
                        ${mappingHtml ? `<ul style="margin: 4px 0 4px 18px; font-size: 11px;">${mappingHtml}</ul>` : ""}
                        <div style="margin-top: 6px;"><strong>Oportunidades a procesar:</strong> ${p.opportunities_count}</div>
                        ${breakdownHtml}
                    </div>
                </div>
            `;
        }).join("");

        const isLive = window.AppMode.isLive;
        const ok = await appConfirm({
            title: isLive ? "Migrar oportunidades (modo LIVE)" : "Plan de migración (modo Simulación)",
            severity: isLive ? "danger" : "info",
            message: `
                <p style="margin-bottom: 10px;">${escapeHtmlAttr(preview.message || "")}</p>
                <div style="max-height: 320px; overflow-y: auto;">${plansHTML}</div>
                ${isLive
                    ? `<div class="app-modal-meta"><strong>Atención:</strong> Estás en modo LIVE. Al confirmar, las oportunidades se moverán de verdad en Bucéfalo y se registrará un run_id de auditoría.</div>`
                    : `<div class="app-modal-meta">Estás en modo Simulación. Confirmar solo cierra este preview — nada se aplicará. Cambia a Live desde el toggle del header para aplicar.</div>`
                }
            `,
            confirmText: isLive ? "Sí, aplicar en Bucéfalo" : "Entendido",
            cancelText: "Cancelar",
        });

        if (!ok) return;

        // 3. Si está en dry-run global, solo cierra el preview.
        if (!isLive) {
            showToast(`Modo Simulación: ${preview.totals.opportunities} oportunidad(es) habrían sido movidas.`, "info");
            return;
        }

        // 4. Live apply.
        btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Migrando…`;
        const applyRes = await mutateFetch(`/api/pipelines/${encodeURIComponent(locationId)}/dedupe`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: "{}",
        });
        const apply = await applyRes.json();

        if (!applyRes.ok) {
            const detail = (apply && apply.detail) || "Error al aplicar la migración.";
            showToast(typeof detail === "string" ? detail : JSON.stringify(detail), "error");
            return;
        }

        const t = apply.totals || {};
        const movedTotal = (t.moved || 0) + (t.merged_obsolete_won || 0) + (t.merged_official_won || 0);
        const detailParts = [];
        detailParts.push(`${t.moved || 0} movidas`);
        if (t.merged_obsolete_won) detailParts.push(`${t.merged_obsolete_won} merge(obs)`);
        if (t.merged_official_won) detailParts.push(`${t.merged_official_won} merge(of)`);
        if (t.skipped) detailParts.push(`${t.skipped} saltadas`);
        if (t.duplicates_in_official) detailParts.push(`${t.duplicates_in_official} DUP`);
        if (t.failed) detailParts.push(`${t.failed} fallidas`);
        showToast(
            `Migración: ${detailParts.join(", ")}. run_id=${apply.run_id || "—"}`,
            (t.failed || 0) > 0 ? "error" : "success"
        );
        // Refresh overview (los conteos cambian).
        try { await loadBranches(); } catch (_) {}
        try { selectBranch(activeBranchId); } catch (_) {}
    } catch (err) {
        showToast(`Error de red en dedupe: ${err.message || err}`, "error");
    } finally {
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}

async function loadSyncHistory() {
    try {
        const tbody = document.getElementById("sync-history-rows");
        if (!tbody) return;

        const response = await fetch("/api/branches");
        const allBranches = await response.json();
        
        let logs = [];
        allBranches.forEach(b => {
            if (b.metrics && b.metrics.last_sync) {
                logs.push({
                    name: b.nombre,
                    ...b.metrics.last_sync
                });
            }
        });
        
        // Ordenar por fecha de inicio descendente
        logs.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
        
        tbody.innerHTML = "";
        
        if (logs.length === 0) {
            tbody.innerHTML = `<tr><td colspan="5" class="text-center">No se encontraron sincronizaciones previas.</td></tr>`;
            return;
        }
        
        logs.slice(0, 6).forEach(log => {
            const statusClass = log.status === "success" ? "success" : (log.status === "running" ? "warning" : "danger");
            const statusLabel = log.status === "success" ? "Éxito" : (log.status === "running" ? "Sincronizando..." : "Falló");
            
            const tr = document.createElement("tr");
            tr.innerHTML = `
                <td><strong>${log.name.replace(/^\d+\s*-\s*MP\s*-\s*/, "")}</strong></td>
                <td>${log.started_at.replace("T", " ").substring(0, 19)}</td>
                <td><span class="badge badge-${statusClass}">${statusLabel}</span></td>
                <td>${log.contacts_synced}</td>
                <td>${log.opps_synced}</td>
            `;
            tbody.appendChild(tr);
        });
    } catch (e) {
        console.error("Error cargando historial de sincronización", e);
    }
}

// --- 2. CONTACTOS CONTROLLER ---

/**
 * Refresca el estado visual del botón "Solo sin oportunidad" y la línea de status
 * según `contactsWithoutOppFilter` y la sucursal activa.
 */
function syncWithoutOppToggle() {
    const btn = document.getElementById("btn-filter-without-opp");
    const status = document.getElementById("contacts-filter-status");
    if (!btn) return;

    const isGlobal = activeBranchId === "global";
    btn.disabled = isGlobal;
    btn.style.opacity = isGlobal ? "0.5" : "1";

    const active = contactsWithoutOppFilter && !isGlobal;
    btn.classList.toggle("btn-primary", active);
    btn.classList.toggle("btn-secondary", !active);
    btn.setAttribute("aria-pressed", String(active));
    btn.innerHTML = active
        ? `<i class="fa-solid fa-user-slash"></i> Filtrando: sin oportunidad <i class="fa-solid fa-xmark" style="margin-left: 6px;"></i>`
        : `<i class="fa-solid fa-user-slash"></i> Solo sin oportunidad`;

    // El botón bulk de "Crear oportunidades para todos" solo es visible cuando
    // está activo el filtro "Solo sin oportunidad" en una sucursal específica
    // y hay al menos un contacto sin opp en esa sucursal.
    const bulkBtn = document.getElementById("btn-bulk-create-opps");
    if (bulkBtn) {
        let show = false;
        let count = 0;
        if (active) {
            const b = branchesData.find(x => x.location_id === activeBranchId);
            count = b?.metrics?.contacts_without_opp_count ?? 0;
            show = count > 0;
        }
        bulkBtn.style.display = show ? "" : "none";
        if (show) {
            bulkBtn.innerHTML = `<i class="fa-solid fa-layer-group"></i> Crear oportunidades para los ${count}`;
        }
    }

    if (status) {
        if (isGlobal) {
            status.textContent = "Selecciona una sucursal para usar este filtro.";
        } else if (active) {
            const b = branchesData.find(x => x.location_id === activeBranchId);
            const n = b?.metrics?.contacts_without_opp_count ?? 0;
            status.textContent = `Mostrando ${n} contactos sin oportunidad en esta sucursal.`;
        } else {
            status.textContent = "";
        }
    }
}

// URL base del CRM (Bucefalo) — los contactos abren en una pestaña nueva.
const BUCEFALO_CRM_BASE = "https://crm.bucefalocrm.io";

function buildContactCrmUrl(locationId, contactId) {
    return `${BUCEFALO_CRM_BASE}/v2/location/${encodeURIComponent(locationId)}/contacts/detail/${encodeURIComponent(contactId)}`;
}

function escapeHtmlAttr(s) {
    return String(s ?? "")
        .replaceAll("&", "&amp;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#39;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;");
}

async function loadContactsTable() {
    const tbody = document.getElementById("contacts-table-rows");
    syncWithoutOppToggle();

    // El header "Acción" se decide tras conocer si hay contactos test en la pagina.
    // Mostrar header si: (a) filtro sin-opp activo en sucursal (botón Crear opp),
    // (b) hay al menos un contacto test en la pagina actual (botón Eliminar).
    const withoutOppActionApplies = contactsWithoutOppFilter && activeBranchId !== "global";
    const actionHeader = document.getElementById("contacts-action-header");

    if (activeBranchId === "global") {
        if (actionHeader) actionHeader.style.display = "none";
        tbody.innerHTML = `
            <tr>
                <td colspan="6" class="text-center" style="padding: 40px;">
                    <div style="font-size: 32px; color: var(--text-muted); margin-bottom: 12px;"><i class="fa-solid fa-users-viewfinder"></i></div>
                    <h4 style="color: var(--text-primary); margin-bottom: 8px;">Visualización de Contactos por Sucursal</h4>
                    <p style="color: var(--text-secondary); max-width: 500px; margin: 0 auto; font-size: 13px;">
                        Para ver y filtrar contactos locales, selecciona una sucursal específica de Monte Providencia en la barra lateral izquierda.
                    </p>
                </td>
            </tr>
        `;
        document.getElementById("pagination-info").innerText = "Mostrando 0 - 0 de 0";
        document.getElementById("btn-page-prev").disabled = true;
        document.getElementById("btn-page-next").disabled = true;
        return;
    }

    const loadingMsg = contactsWithoutOppFilter
        ? "Cargando contactos sin oportunidad..."
        : "Cargando contactos locales cacheables...";
    tbody.innerHTML = `<tr><td colspan="7" class="text-center"><i class="fa-solid fa-spinner fa-spin"></i> ${loadingMsg}</td></tr>`;

    try {
        let url = `/api/contacts/${activeBranchId}?q=${encodeURIComponent(contactsSearchQuery)}&page=${currentContactsPage}&limit=100`;
        if (contactsWithoutOppFilter) url += "&without_opp=true";
        const response = await fetch(url);
        const data = await response.json();

        tbody.innerHTML = "";
        const contacts = data.contacts || [];
        // El botón Eliminar va en cada fila, así que la columna Acción aparece
        // siempre que estemos en una sucursal específica (no global) y haya o vaya
        // a haber filas. Para placeholder "no se encontraron contactos" igual ocultamos.
        const showActionCol = contacts.length > 0;
        if (actionHeader) actionHeader.style.display = showActionCol ? "" : "none";
        const colspan = showActionCol ? 10 : 9;

        if (contacts.length === 0) {
            const emptyMsg = contactsWithoutOppFilter
                ? "No hay contactos sin oportunidad en esta sucursal."
                : "No se encontraron contactos en esta sucursal.";
            tbody.innerHTML = `<tr><td colspan="${colspan}" class="text-center">${emptyMsg}</td></tr>`;
            document.getElementById("pagination-info").innerText = "Mostrando 0 - 0 de 0";
            document.getElementById("btn-page-prev").disabled = true;
            document.getElementById("btn-page-next").disabled = true;
            return;
        }

        contacts.forEach(c => {
            const name = `${c.first_name || ""} ${c.last_name || ""}`.trim();
            const displayName = name || "Sin nombre";
            const tags = c.tags || [];
            const tagsHTML = tags.slice(0, 3).map(t => `<span class="tag-badge">${escapeHtmlAttr(t)}</span>`).join("");
            const tagsRemaining = tags.length > 3 ? `<span class="tag-badge">+${tags.length - 3}</span>` : "";

            // Nombre como hipervinculo al CRM de Bucefalo (abre en nueva pestana).
            const crmUrl = buildContactCrmUrl(activeBranchId, c.id);
            const testReasonsAttr = escapeHtmlAttr((c.test_reasons || []).join(" · ") || (c.is_test ? "Detectado como prueba/test" : ""));
            const testBadge = c.is_test
                ? ` <span class="tag-badge" style="background: rgba(255, 159, 64, 0.2); color: #ff9f40; border: 1px solid rgba(255,159,64,0.4); font-size: 10px; margin-left: 6px;" title="${testReasonsAttr}">PRUEBA</span>`
                : "";
            const nameCellHTML = `<td>
                <a href="${crmUrl}" target="_blank" rel="noopener noreferrer" class="contact-name-link" title="Abrir contacto en Bucéfalo">
                    <strong>${escapeHtmlAttr(displayName)}</strong>
                </a>${testBadge}
            </td>`;

            // Celda Acción: el botón Eliminar siempre está presente (toda fila se puede borrar
            // bajo confirmación). El botón Crear oportunidad solo cuando el filtro sin-opp está activo.
            const buttonsHTML = [];
            if (withoutOppActionApplies) {
                buttonsHTML.push(`<button class="btn btn-primary btn-sm btn-create-opp"
                        data-location-id="${activeBranchId}"
                        data-contact-id="${escapeHtmlAttr(c.id)}"
                        data-contact-name="${escapeHtmlAttr(displayName || c.email || c.phone || c.id)}"
                        title="Crea la oportunidad para este contacto en la última etapa del pipeline 'Standar', copiando sucursal/tienda/fuente/canal del contacto.">
                        <i class="fa-solid fa-plus"></i> Crear oportunidad
                    </button>`);
            }
            const deleteTitle = c.is_test
                ? `Marcado como prueba: ${(c.test_reasons || []).join(" · ") || "—"}`
                : "Eliminar contacto (definitivo, hace cascade a oportunidades)";
            buttonsHTML.push(`<button class="btn btn-danger btn-sm btn-delete-contact"
                    data-location-id="${activeBranchId}"
                    data-contact-id="${escapeHtmlAttr(c.id)}"
                    data-contact-name="${escapeHtmlAttr(displayName || c.email || c.phone || c.id)}"
                    data-is-test="${c.is_test ? '1' : '0'}"
                    data-test-reasons="${testReasonsAttr}"
                    title="${escapeHtmlAttr(deleteTitle)}">
                    <i class="fa-solid fa-trash"></i> Eliminar
                </button>`);
            const actionCellHTML = `<td><div style="display:flex;gap:6px;justify-content:flex-end;">${buttonsHTML.join("")}</div></td>`;

            // Fuente nativa de Bucéfalo (source). Si está vacía mostramos placeholder gris.
            const sourceHTML = c.source
                ? `<span class="source-badge">${escapeHtmlAttr(c.source)}</span>`
                : '<span class="text-muted">—</span>';

            // Info de vehículo (custom fields del contacto). Placeholder gris si falta.
            const vehCell = v => v ? escapeHtmlAttr(v) : '<span class="text-muted">—</span>';

            const tr = document.createElement("tr");
            if (c.is_test) tr.classList.add("contact-row-test");
            tr.innerHTML = `
                ${nameCellHTML}
                <td>${c.phone || '<span class="text-muted">N/A</span>'}</td>
                <td>${c.email || '<span class="text-muted">N/A</span>'}</td>
                <td>${sourceHTML}</td>
                <td>${tagsHTML}${tagsRemaining || (tags.length === 0 ? '<span class="text-muted">Ninguna</span>' : '')}</td>
                <td>${vehCell(c.marca_vehiculo)}</td>
                <td>${vehCell(c.version_vehiculo)}</td>
                <td>${vehCell(c.ano_vehiculo)}</td>
                <td>${c.date_added ? c.date_added.substring(0, 10) : "N/A"}</td>
                ${actionCellHTML}
            `;
            tbody.appendChild(tr);
        });

        if (withoutOppActionApplies) {
            tbody.querySelectorAll(".btn-create-opp").forEach(btn => {
                btn.addEventListener("click", () => createOpportunityForContact(btn));
            });
        }
        tbody.querySelectorAll(".btn-delete-contact").forEach(btn => {
            btn.addEventListener("click", () => deleteContact(btn, { isTest: btn.dataset.isTest === "1" }));
        });

        // Paginación lógica simple — total y test_count vienen del backend.
        const startIdx = (currentContactsPage - 1) * 100 + 1;
        const endIdx = startIdx + contacts.length - 1;
        const total = typeof data.total === "number" ? data.total : contacts.length;
        const testCount = typeof data.test_count === "number" ? data.test_count : 0;
        const testSuffix = testCount > 0 ? ` · ${testCount} de prueba (al inicio)` : "";
        document.getElementById("pagination-info").innerText = `Mostrando ${startIdx} - ${endIdx} de ${total}${testSuffix}`;

        document.getElementById("btn-page-prev").disabled = currentContactsPage === 1;
        document.getElementById("btn-page-next").disabled = endIdx >= total;

    } catch (e) {
        if (actionHeader) actionHeader.style.display = "none";
        tbody.innerHTML = `<tr><td colspan="9" class="text-center text-danger">Error cargando contactos locales. Sincroniza la cuenta si está vacía.</td></tr>`;
    }
}

/**
 * Elimina un contacto via endpoint DELETE /api/comparativa/contact (borra en
 * Bucéfalo y limpia cache SQLite). La confirmación SIEMPRE pasa por el modal
 * interno appConfirm — nada de confirm() nativo del navegador — para que las
 * acciones queden registradas en el flujo de la plataforma.
 *
 * Funciona tanto para contactos de prueba (badge PRUEBA) como para contactos
 * normales. El mensaje se adapta según `isTest`. GHL no permite undelete, así
 * que es destructivo permanente.
 */
async function deleteContact(btn, { isTest = false } = {}) {
    const locationId = btn.dataset.locationId;
    const contactId = btn.dataset.contactId;
    const contactName = btn.dataset.contactName || contactId;
    const reasonsAttr = btn.dataset.testReasons || "";

    const reasonsBlock = (isTest && reasonsAttr)
        ? `<div class="app-modal-meta"><strong>Motivos detectados:</strong> ${reasonsAttr}</div>`
        : "";
    const intro = isTest
        ? `Vas a eliminar un contacto marcado como <strong>prueba/test</strong>:`
        : `Vas a eliminar el contacto:`;

    const ok = await appConfirm({
        title: "Eliminar contacto",
        severity: "danger",
        message: `
            ${intro}<br><br>
            <strong>${contactName}</strong><br>
            <code style="font-size: 11px; color: var(--text-muted);">${contactId}</code><br><br>
            Esta acción borra el contacto en <strong>Bucéfalo</strong> de forma definitiva
            (no hay rollback) y elimina sus <strong>oportunidades en cascada</strong>.
            ${reasonsBlock}
        `,
        confirmText: "Eliminar definitivamente",
        cancelText: "Cancelar",
    });
    if (!ok) return;

    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Eliminando...`;

    try {
        const url = `/api/comparativa/contact?contact_id=${encodeURIComponent(contactId)}&location_id=${encodeURIComponent(locationId)}`;
        const response = await mutateFetch(url, { method: "DELETE" });
        const data = await response.json();

        if (!response.ok) {
            const detail = (data && data.detail) || "No se pudo eliminar el contacto.";
            showToast(`${contactName}: ${detail}`, "error");
            btn.disabled = false;
            btn.innerHTML = originalHTML;
            return;
        }

        const oppsRemoved = data.local_opps_removed || 0;
        showToast(
            `Contacto "${contactName}" eliminado.${oppsRemoved > 0 ? ` (${oppsRemoved} oportunidades en cascada)` : ""}`,
            "success"
        );

        // Sacar la fila de la tabla sin recargar todo.
        const tr = btn.closest("tr");
        if (tr) tr.remove();

        // Refrescar contadores globales (badge sidebar, etc).
        try { await loadBranches(); } catch (_) { /* tolerar */ }
    } catch (err) {
        showToast(`Error de red al eliminar el contacto: ${err.message || err}`, "error");
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}


/**
 * Crea una oportunidad en GHL para un contacto que no tiene ninguna.
 * Llama al endpoint POST /api/contacts/{locationId}/{contactId}/opportunity.
 * El backend valida pipeline 'Standar'/8/'En Pausa' y mapea custom fields
 * (sucursal, tienda, fuente_prospecto, canal_origen, vehiculo) del contacto
 * a la oportunidad. Si falta cualquiera de los 4 requeridos, aborta y se
 * muestra el detalle en un toast de error.
 */
async function createOpportunityForContact(btn) {
    const locationId = btn.dataset.locationId;
    const contactId = btn.dataset.contactId;
    const contactName = btn.dataset.contactName || contactId;
    const originalHTML = btn.innerHTML;

    btn.disabled = true;
    btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Creando...`;

    try {
        const response = await mutateFetch(`/api/contacts/${encodeURIComponent(locationId)}/${encodeURIComponent(contactId)}/opportunity`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: "{}",
        });
        const data = await response.json();

        if (!response.ok) {
            // El backend devuelve 409 con detail={ok:false, error, error_code} para
            // errores de negocio (pipeline inválido, falta campo, contacto ya tiene opp).
            const detail = data && data.detail;
            const errMsg = (detail && typeof detail === "object" && detail.error)
                ? detail.error
                : (typeof detail === "string" ? detail : "No se pudo crear la oportunidad.");
            showToast(`${contactName}: ${errMsg}`, "error");
            btn.disabled = false;
            btn.innerHTML = originalHTML;
            return;
        }

        const oppId = data.opportunity_id || "(sin id)";
        showToast(`Oportunidad creada para ${contactName} en etapa '${data.stage_name}'. ID: ${oppId}`, "success");

        // Refrescar la fila visualmente: se va de la tabla "sin oportunidad".
        const tr = btn.closest("tr");
        if (tr) tr.remove();

        // Refrescar el contador del header/sidebar para reflejar la baja
        // del badge "sin oportunidad" en esta sucursal.
        try {
            await loadBranches();
        } catch (_) { /* tolerar */ }

        // loadBranches refresca la sidebar, pero el botón bulk, el texto de
        // status y los chips del header de la cuenta activa leen branchesData
        // a través de otras funciones — refrescarlas explícitamente para que
        // el flujo individual quede consistente con el flujo bulk.
        syncWithoutOppToggle();
        updateActiveInfoSummary();
    } catch (err) {
        showToast(`Error de red al crear la oportunidad: ${err.message || err}`, "error");
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}

/**
 * Bulk: crea oportunidades para TODOS los contactos sin oportunidad de la
 * sucursal activa. Llama al endpoint POST /api/contacts/{loc}/bulk-create-opportunities
 * que internamente itera y registra todo bajo un único run_id en script_audit.
 *
 * Confirmación SIEMPRE via appConfirm (modal interno) — nunca confirm() nativo.
 * El backend puede tardar varios minutos para sucursales grandes; se muestra
 * spinner en el botón y un toast con el resumen al terminar.
 */
async function bulkCreateOpportunities() {
    if (activeBranchId === "global") return;
    const btn = document.getElementById("btn-bulk-create-opps");
    if (!btn) return;

    const branch = branchesData.find(x => x.location_id === activeBranchId);
    const branchName = branch?.nombre || activeBranchId;
    const count = branch?.metrics?.contacts_without_opp_count ?? 0;
    if (count === 0) {
        showToast("No hay contactos sin oportunidad en esta sucursal.", "info");
        return;
    }

    const ok = await appConfirm({
        title: "Crear oportunidades en lote",
        severity: "warning",
        message: `
            Vas a crear oportunidades para los <strong>${count} contactos sin oportunidad</strong>
            de la sucursal <strong>${escapeHtmlAttr(branchName)}</strong>.<br><br>
            Cada oportunidad se crea con:
            <ul style="margin: 8px 0 8px 18px;">
                <li>Estatus <code>open</code> y pipeline <code>Standar</code> (última etapa).</li>
                <li>Custom fields copiados del contacto (sucursal, tienda, fuente, canal).</li>
                <li>Si al contacto le faltan los 4 campos requeridos, esa fila se <strong>salta</strong> y se reporta.</li>
            </ul>
            Todos los cambios quedan registrados bajo un mismo <code>run_id</code> en auditoría.
            <br><br>
            <strong>Importante:</strong> a ${count} contactos esto puede tardar varios minutos por el rate limit de Bucéfalo.
        `,
        confirmText: `Crear las ${count} oportunidades`,
        cancelText: "Cancelar",
    });
    if (!ok) return;

    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Procesando ${count} contactos…`;

    try {
        const response = await mutateFetch(
            `/api/contacts/${encodeURIComponent(activeBranchId)}/bulk-create-opportunities`,
            { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
        );
        const data = await response.json();

        if (!response.ok) {
            const detail = (data && data.detail) || "No se pudo procesar el lote.";
            showToast(`Error en bulk: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`, "error");
            btn.disabled = false;
            btn.innerHTML = originalHTML;
            return;
        }

        const t = data.totals || {};
        const runId = data.run_id || "(sin id)";
        // Muestra resumen detallado en un modal interno para que el usuario
        // pueda copiar el run_id y revisar saltados/fallidos antes de seguir.
        const skippedList = (data.details || []).filter(d => !d.ok && (d.error_code === "missing_custom_fields" || d.error_code === "contact_has_opportunity")).slice(0, 10);
        const failedList = (data.details || []).filter(d => !d.ok && d.error_code !== "missing_custom_fields" && d.error_code !== "contact_has_opportunity").slice(0, 10);

        const renderList = (items, emptyMsg) => items.length
            ? `<ul style="margin: 6px 0 0 18px; font-size: 12px;">${items.map(d => `<li><strong>${escapeHtmlAttr(d.name || d.contact_id)}</strong>: ${escapeHtmlAttr(d.error || "")}</li>`).join("")}</ul>`
            : `<div class="text-muted" style="font-size: 12px;">${emptyMsg}</div>`;

        await appConfirm({
            title: "Resumen de creación en lote",
            severity: t.failed > 0 ? "warning" : "info",
            message: `
                <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px;">
                    <div><strong>Procesados:</strong> ${t.processed}</div>
                    <div><strong>Creadas:</strong> ${t.created}</div>
                    <div><strong>Saltadas:</strong> ${t.skipped}</div>
                    <div><strong>Fallidas:</strong> ${t.failed}</div>
                </div>
                <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 10px;">
                    Run de auditoría: <code>${runId}</code>
                </div>
                <details ${t.skipped > 0 ? "open" : ""}>
                    <summary style="cursor:pointer;"><strong>Saltadas</strong> (${t.skipped})</summary>
                    ${renderList(skippedList, "Sin saltadas.")}
                </details>
                <details ${t.failed > 0 ? "open" : ""} style="margin-top: 6px;">
                    <summary style="cursor:pointer;"><strong>Fallidas</strong> (${t.failed})</summary>
                    ${renderList(failedList, "Sin fallos.")}
                </details>
            `,
            confirmText: "Cerrar",
            cancelText: "Cancelar",
        });

        showToast(
            `Bulk listo: ${t.created} creadas, ${t.skipped} saltadas, ${t.failed} fallidas.`,
            t.failed > 0 ? "error" : "success"
        );

        // Refrescar contadores y la tabla.
        try { await loadBranches(); } catch (_) {}
        currentContactsPage = 1;
        await loadContactsTable();
    } catch (err) {
        showToast(`Error de red en bulk: ${err.message || err}`, "error");
    } finally {
        btn.disabled = false;
        // El innerHTML real lo refresca syncWithoutOppToggle si sigue visible.
        btn.innerHTML = originalHTML;
    }
}


// --- 3. KANBAN TAB CONTROLLER ---

async function loadPipelinesAndBoard() {
    const select = document.getElementById("pipeline-select");
    const board = document.getElementById("kanban-board");
    
    if (activeBranchId === "global") {
        select.innerHTML = `<option value="">-- Selecciona una sucursal --</option>`;
        board.innerHTML = `
            <div class="board-empty-state">
                <i class="fa-solid fa-network-wired"></i>
                <p>Flujo Kanban de Oportunidades por Sucursal</p>
                <span class="text-secondary">El tablero Kanban visualiza las etapas de venta de una sucursal individual. Selecciona una sucursal en la barra lateral para continuar.</span>
            </div>
        `;
        return;
    }
    
    select.innerHTML = `<option value="">Cargando pipelines...</option>`;
    
    try {
        const response = await fetch(`/api/pipelines/${activeBranchId}`);
        const data = await response.json();
        
        select.innerHTML = "";
        const pipelines = data.pipelines;
        
        if (!pipelines || pipelines.length === 0) {
            select.innerHTML = `<option value="">Sin pipelines</option>`;
            renderEmptyBoard();
            return;
        }
        
        pipelines.forEach(p => {
            const opt = document.createElement("option");
            opt.value = p.id;
            opt.innerText = p.name;
            select.appendChild(opt);
        });
        
        // Seleccionar primer pipeline por defecto
        if (!activePipelineId || !pipelines.find(p => p.id === activePipelineId)) {
            activePipelineId = pipelines[0].id;
        }
        select.value = activePipelineId;
        
        loadKanbanBoard();
    } catch (e) {
        select.innerHTML = `<option value="">Error cargando pipelines</option>`;
        renderEmptyBoard();
    }
}

function renderEmptyBoard() {
    const board = document.getElementById("kanban-board");
    board.innerHTML = `
        <div class="board-empty-state">
            <i class="fa-solid fa-circle-nodes"></i>
            <p>No hay pipelines en esta cuenta.</p>
            <span class="text-muted">Prueba sincronizar los datos de GHL o valida que la cuenta tenga pipelines activos.</span>
        </div>
    `;
}

async function loadKanbanBoard() {
    const board = document.getElementById("kanban-board");
    board.innerHTML = `<div class="board-empty-state"><i class="fa-solid fa-spinner fa-spin"></i> Cargando tablero Kanban cacheado...</div>`;
    
    if (!activePipelineId) {
        renderEmptyBoard();
        return;
    }
    
    try {
        // Cargar pipeline seleccionado
        const pipeResp = await fetch(`/api/pipelines/${activeBranchId}`);
        const pipeData = await pipeResp.json();
        const pipeline = pipeData.pipelines.find(p => p.id === activePipelineId);
        
        // Cargar oportunidades
        const oppsResp = await fetch(`/api/opportunities/${activeBranchId}?pipeline_id=${activePipelineId}`);
        const oppsData = await oppsResp.json();
        const opportunities = oppsData.opportunities;
        
        board.innerHTML = "";
        
        if (!pipeline || !pipeline.stages || pipeline.stages.length === 0) {
            renderEmptyBoard();
            return;
        }
        
        pipeline.stages.forEach(stage => {
            const col = document.createElement("div");
            col.className = "kanban-column";
            
            // Filtrar oportunidades de esta etapa
            const stageOpps = opportunities.filter(o => o.pipeline_stage_id === stage.id);
            
            col.innerHTML = `
                <div class="kanban-column-header">
                    <h4 title="${stage.name}">${stage.name}</h4>
                    <span class="kanban-card-count" id="count-stage-${stage.id}">${stageOpps.length}</span>
                </div>
                <div class="kanban-cards-wrapper" data-stage-id="${stage.id}">
                    <!-- Cartas -->
                </div>
            `;
            
            const cardWrapper = col.querySelector(".kanban-cards-wrapper");
            
            if (stageOpps.length === 0) {
                // Column vacía
            } else {
                stageOpps.forEach(opp => {
                    const card = document.createElement("div");
                    card.className = "kanban-card";
                    card.setAttribute("draggable", "true");
                    card.setAttribute("data-opp-id", opp.id);
                    
                    const clientName = `${opp.first_name || ""} ${opp.last_name || ""}`.strip ? `${opp.first_name || ""} ${opp.last_name || ""}`.strip() : `${opp.first_name || ""} ${opp.last_name || ""}`;
                    const statusClass = opp.status === "won" ? "badge-success" : (opp.status === "lost" ? "badge-danger" : "badge-primary");
                    const statusLabel = opp.status ? opp.status.toUpperCase() : "OPEN";

                    const vehiculoHTML = opp.vehiculo
                        ? `<div class="kanban-card-vehicle"><i class="fa-solid fa-car"></i> <span>${escapeHtmlAttr(opp.vehiculo)}</span></div>`
                        : "";

                    card.innerHTML = `
                        <div class="kanban-card-title">${opp.name || "Sin nombre"}</div>
                        <div class="kanban-card-contact">
                            <i class="fa-solid fa-user-tag"></i> <span>${clientName || "Sin cliente"}</span>
                        </div>
                        ${vehiculoHTML}
                        <div class="kanban-card-footer">
                            <span class="badge ${statusClass}">${statusLabel}</span>
                            <span class="kanban-card-value">${formatCurrency(opp.monetary_value)}</span>
                        </div>
                    `;
                    cardWrapper.appendChild(card);
                });
            }
            
            board.appendChild(col);
        });
        
    } catch (e) {
        board.innerHTML = `<div class="board-empty-state text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Error renderizando el tablero de oportunidades.</div>`;
    }
}

// --- 4. SCRIPTS TAB CONTROLLER ---

async function loadScriptsCatalog() {
    const listContainer = document.getElementById("scripts-list");
    
    // Si ya tenemos los datos cargados y los scripts están renderizados, no recargar ni vaciar la lista
    if (window.allScriptsData && listContainer.querySelector(".script-item-card")) {
        return;
    }
    
    listContainer.innerHTML = `<p class="text-center"><i class="fa-solid fa-spinner fa-spin"></i> Cargando catálogo de scripts...</p>`;
    
    try {
        if (!branchesData || branchesData.length === 0) {
            await loadBranches();
        }
        const response = await fetch("/api/scripts");
        const scripts = await response.json();
        
        window.allScriptsData = scripts; // Guardar global
        renderScriptsByFilter("all");
    } catch (e) {
        listContainer.innerHTML = `<p class="text-center text-danger">Error cargando el catálogo de scripts locales.</p>`;
    }
}

function renderScriptsByFilter(category) {
    const listContainer = document.getElementById("scripts-list");
    listContainer.innerHTML = "";
    activeScriptsCategory = category || "all";
    if (!window.allScriptsData) {
        return;
    }
    
    // Categoría de Botones
    document.querySelectorAll(".btn-category").forEach(btn => {
        btn.classList.remove("active");
        if (btn.getAttribute("data-category") === activeScriptsCategory) {
            btn.classList.add("active");
        }
    });

    // Añadir listener una sola vez a los botones de categoría
    if (!window.categoryTabsInitialized) {
        document.querySelectorAll(".btn-category").forEach(btn => {
            btn.addEventListener("click", () => {
                renderScriptsByFilter(btn.getAttribute("data-category"));
            });
        });
        window.categoryTabsInitialized = true;
    }

    const categoryFiltered = activeScriptsCategory === "all" 
        ? window.allScriptsData 
        : window.allScriptsData.filter(s => s.category === activeScriptsCategory);
    const filtered = categoryFiltered.filter(scriptMatchesSearch);
        
    if (!filtered || filtered.length === 0) {
        const message = scriptsSearchQuery
            ? `No se encontraron scripts para "${escapeHtml(scriptsSearchQuery)}".`
            : "No se encontraron scripts en esta categoría.";
        listContainer.innerHTML = `<p class="text-center text-secondary">${message}</p>`;
        return;
    }
    
    filtered.forEach(s => {
        const card = document.createElement("div");
        card.className = "script-item-card";
        
        const existsClass = s.exists ? "badge-success" : "badge-danger";
        const existsText = s.exists ? "Disponible" : "Esqueleto";
        
        const placeholder = s.args_placeholder ? `placeholder="Argumentos extra: ${escapeHtml(s.args_placeholder)}"` : 'style="display:none;"';
        const scopeControls = s.supports_locations ? buildScriptScopeControls(s.name) : "";
        const customControls = buildScriptCustomControls(s.name);
        const optionControls = buildScriptOptionControls(s);
        const advancedArgs = s.args_placeholder ? buildAdvancedArgsInput(s, placeholder) : "";
        
        card.innerHTML = `
            <div class="script-item-header">
                <div>
                    <h4>${escapeHtml(s.title)}</h4>
                    <p>${escapeHtml(s.name)}</p>
                </div>
                <span class="badge ${existsClass}">${existsText}</span>
            </div>
            <p class="desc">${escapeHtml(s.description)}</p>
            ${scopeControls}
            ${customControls}
            ${optionControls}
            <div class="script-item-actions">
                ${advancedArgs}
                <button class="btn btn-secondary btn-sm btn-edit-script-metadata" data-name="${escapeHtml(s.name)}">
                    <i class="fa-solid fa-tags"></i> Metadata
                </button>
                <button class="btn btn-danger btn-sm btn-delete-script" data-name="${escapeHtml(s.name)}">
                    <i class="fa-solid fa-trash-can"></i> Borrar
                </button>
                <button class="btn btn-primary btn-sm btn-run-script" data-name="${escapeHtml(s.name)}">
                    <i class="fa-solid fa-play"></i> Ejecutar
                </button>
            </div>
        `;
        initScriptScopeControls(card);
        initScriptCustomControls(card, s.name);

        const metadataButton = card.querySelector(".btn-edit-script-metadata");
        if (metadataButton) {
            metadataButton.addEventListener("click", () => openScriptMetadataModal(s.name));
        }

        const deleteButton = card.querySelector(".btn-delete-script");
        if (deleteButton) {
            deleteButton.addEventListener("click", () => deleteScriptFromCatalog(s.name));
        }
        
        // Agregar click event
        card.querySelector(".btn-run-script").addEventListener("click", () => {
            const argsInput = card.querySelector(".script-item-args-input");
            let optionFlags = Array.from(card.querySelectorAll(".script-option-toggle input:checked")).map(input => input.value);
            let manualArgs = argsInput ? argsInput.value.trim() : "";
            
            // Si el script es reconcile_and_sync_opportunities.py, leer la acción seleccionada en la UI
            if (s.name === "reconcile_and_sync_opportunities.py") {
                const selectedAction = card.querySelector('input[name="reconcile-action-type"]:checked')?.value;
                if (selectedAction === "updates-only") {
                    optionFlags.push("--updates-only");
                }
            }

            if (s.name === "sync_contacts_branch_to_brand.py") {
                const contactSyncArgs = getSyncContactsBrandArgs(card);
                if (contactSyncArgs === null) return;
                manualArgs = [contactSyncArgs, manualArgs].filter(Boolean).join(" ");
            }
            
            // Modo universal: leer del estado global AppMode (no del checkbox legacy).
            const isDryRunSelected = window.AppMode.isDryRun;
            
            if (s.mutator === true) {
                if (s.dry_run_mode === "dry_run_flag") {
                    if (isDryRunSelected) {
                        // Asegurar que --dry-run esté presente
                        if (!optionFlags.includes("--dry-run") && !manualArgs.includes("--dry-run")) {
                            optionFlags.push("--dry-run");
                        }
                    } else {
                        // Quitar --dry-run
                        optionFlags = optionFlags.filter(f => f !== "--dry-run");
                        manualArgs = manualArgs.replace(/\b--dry-run\b/g, "").trim();
                    }
                } else if (s.dry_run_mode === "apply_flag") {
                    if (isDryRunSelected) {
                        // Quitar --apply
                        optionFlags = optionFlags.filter(f => f !== "--apply");
                        manualArgs = manualArgs.replace(/\b--apply\b/g, "").trim();
                    } else {
                        // Asegurar que --apply esté presente
                        if (!optionFlags.includes("--apply") && !manualArgs.includes("--apply")) {
                            optionFlags.push("--apply");
                        }
                    }
                }
            }
            
            const argsVal = [...optionFlags, manualArgs].filter(Boolean).join(" ");
            const scopePayload = getScriptScopePayload(card, s);
            if (!scopePayload) return;
            executeScript(s.name, argsVal, scopePayload, Boolean(s.supports_audit));
        });
        
        listContainer.appendChild(card);
    });
}

function scriptMatchesSearch(script) {
    if (!scriptsSearchQuery) return true;
    const optionText = (script.options || [])
        .map(option => `${option.flag || ""} ${option.label || ""} ${option.description || ""}`)
        .join(" ");
    const haystack = [
        script.title,
        script.name,
        script.description,
        script.category,
        script.args_placeholder,
        optionText,
    ].join(" ").toLowerCase();
    return haystack.includes(scriptsSearchQuery);
}

function getScriptApiName(scriptName) {
    return encodeURIComponent(scriptName);
}

async function openScriptMetadataModal(scriptName) {
    try {
        const response = await fetch(`/api/scripts/${getScriptApiName(scriptName)}/metadata`);
        if (!response.ok) throw new Error("No se pudo cargar la metadata del script.");
        const meta = await response.json();
        document.getElementById("script-metadata-name").value = meta.name || scriptName;
        document.getElementById("script-meta-title").value = meta.title || "";
        document.getElementById("script-meta-description").value = meta.description || "";
        document.getElementById("script-meta-category").value = meta.category || "Sin registrar";
        document.getElementById("script-meta-args").value = meta.args_placeholder || "";
        document.getElementById("script-meta-dry-run-mode").value = meta.dry_run_mode || "";
        document.getElementById("script-meta-supports-locations").checked = Boolean(meta.supports_locations);
        document.getElementById("script-meta-mutator").checked = Boolean(meta.mutator);
        document.getElementById("script-meta-supports-audit").checked = Boolean(meta.supports_audit);
        document.getElementById("modal-script-metadata").classList.remove("hidden");
    } catch (e) {
        showToast(e.message, "error");
    }
}

function closeScriptMetadataModal() {
    document.getElementById("modal-script-metadata").classList.add("hidden");
}

async function saveScriptMetadata() {
    const scriptName = document.getElementById("script-metadata-name").value;
    const metadata = {
        title: document.getElementById("script-meta-title").value,
        description: document.getElementById("script-meta-description").value,
        category: document.getElementById("script-meta-category").value,
        args_placeholder: document.getElementById("script-meta-args").value,
        dry_run_mode: document.getElementById("script-meta-dry-run-mode").value,
        supports_locations: document.getElementById("script-meta-supports-locations").checked,
        mutator: document.getElementById("script-meta-mutator").checked,
        supports_audit: document.getElementById("script-meta-supports-audit").checked,
    };

    try {
        const response = await fetch(`/api/scripts/${getScriptApiName(scriptName)}/metadata`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ metadata })
        });
        const data = await response.json();
        if (!response.ok || !data.success) throw new Error(data.detail || "No se pudo guardar la metadata.");
        window.allScriptsData = await fetch("/api/scripts").then(r => r.json());
        closeScriptMetadataModal();
        renderScriptsByFilter(activeScriptsCategory);
        showToast("Metadata guardada.", "success");
    } catch (e) {
        showToast(e.message, "error");
    }
}

async function deleteScriptFromCatalog(scriptName) {
    const confirmed = await appConfirm({
        title: "Borrar script del catálogo",
        message: `Vas a borrar el script ${scriptName}.\n\nEsto eliminará el archivo .py si existe y borrará/ocultará su metadata asociada. Esta acción no se puede deshacer desde el UI.`,
        confirmText: "Borrar",
        severity: "danger",
    });
    if (!confirmed) return;

    try {
        const response = await fetch(`/api/scripts/${getScriptApiName(scriptName)}`, { method: "DELETE" });
        const data = await response.json();
        if (!response.ok || !data.success) throw new Error(data.detail || "No se pudo borrar el script.");
        window.allScriptsData = await fetch("/api/scripts").then(r => r.json());
        renderScriptsByFilter(activeScriptsCategory);
        showToast(`Script ${scriptName} borrado del catálogo.`, "success");
    } catch (e) {
        showToast(e.message, "error");
    }
}

function buildScriptOptionControls(script) {
    if (!script.options || script.options.length === 0) return "";

    const visibleOptions = script.options.filter(option => option.flag !== "--dry-run" && option.flag !== "--apply");
    if (visibleOptions.length === 0) return "";

    const options = visibleOptions.map(option => `
        <label class="script-option-toggle" title="${escapeHtml(option.description || "")}">
            <input type="checkbox" value="${escapeHtml(option.flag)}">
            <span>
                <strong>${escapeHtml(option.label)}</strong>
                <small>${escapeHtml(option.flag)}</small>
            </span>
        </label>
    `).join("");

    return `
        <div class="script-options-panel">
            <div class="script-panel-label">Opciones</div>
            <div class="script-options-grid">${options}</div>
        </div>
    `;
}

function buildScriptCustomControls(scriptName) {
    if (scriptName === "reconcile_and_sync_opportunities.py") {
        return `
            <div class="script-panel-label" style="margin-top: 12px;">Acciones Permitidas</div>
            <div class="script-mode-row" id="reconcile-actions-row" style="margin-bottom: 12px;">
                <label class="script-mode-choice reconcile-action-choice active">
                    <input type="radio" name="reconcile-action-type" value="all" checked style="display:none;">
                    <span>Sincronización Completa</span>
                </label>
                <label class="script-mode-choice reconcile-action-choice">
                    <input type="radio" name="reconcile-action-type" value="updates-only" style="display:none;">
                    <span>Solo Actualizaciones (PUT)</span>
                </label>
            </div>
        `;
    }
    if (scriptName === "sync_contacts_branch_to_brand.py") {
        return `
            <div class="script-options-panel" data-contact-sync-controls="true">
                <div class="script-panel-label">Datos de Contactos</div>
                <div class="script-options-grid">
                    <label class="script-option-toggle" title="Procesa solo esta cantidad de contactos por sucursal. Útil para pruebas dry-run.">
                        <span>
                            <strong>Límite por sucursal</strong>
                            <input type="number" class="script-contact-sync-limit" min="1" placeholder="Ej. 20">
                        </span>
                    </label>
                    <label class="script-option-toggle" title="Cantidad máxima de contactos a descargar desde Marca y cada sucursal.">
                        <span>
                            <strong>Máximo a descargar</strong>
                            <input type="number" class="script-contact-sync-max" min="1" placeholder="4000">
                        </span>
                    </label>
                </div>
                <label class="script-option-toggle" title="Opcional. Si se deja vacío, copia todos los campos personalizados que existan por nombre en sucursal y marca.">
                    <span>
                        <strong>Campos personalizados específicos</strong>
                        <input type="text" class="script-contact-sync-fields" placeholder="Sucursal, TIENDA, Canal de Origen">
                        <small>Vacío = todos los campos personalizados compatibles</small>
                    </span>
                </label>
                <div class="script-scope-help">Las sucursales solo se leen. Los POST/PUT se hacen únicamente en la cuenta de Marca.</div>
            </div>
        `;
    }
    return "";
}

function shellQuoteArg(value) {
    return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}

function getPositiveNumberArg(card, selector, flag) {
    const input = card.querySelector(selector);
    const rawValue = input ? input.value.trim() : "";
    if (!rawValue) return "";
    const numberValue = Number(rawValue);
    if (!Number.isInteger(numberValue) || numberValue < 1) {
        showToast(`${flag} debe ser un número entero mayor a 0.`, "error");
        return null;
    }
    return `${flag} ${numberValue}`;
}

function getSyncContactsBrandArgs(card) {
    const args = [];
    const limitArg = getPositiveNumberArg(card, ".script-contact-sync-limit", "--limit");
    if (limitArg === null) return null;
    if (limitArg) args.push(limitArg);

    const maxArg = getPositiveNumberArg(card, ".script-contact-sync-max", "--max-contacts");
    if (maxArg === null) return null;
    if (maxArg) args.push(maxArg);

    const fields = card.querySelector(".script-contact-sync-fields")?.value.trim();
    if (fields) {
        args.push(`--fields ${shellQuoteArg(fields)}`);
    }
    return args.join(" ");
}

function initScriptCustomControls(card, scriptName) {
    if (scriptName === "reconcile_and_sync_opportunities.py") {
        const choices = card.querySelectorAll(".reconcile-action-choice");
        choices.forEach(choice => {
            choice.addEventListener("click", () => {
                choices.forEach(c => c.classList.remove("active"));
                choice.classList.add("active");
                const input = choice.querySelector("input");
                if (input) {
                    input.checked = true;
                    // Forzar trigger de cambio para GHL styles
                    input.dispatchEvent(new Event("change"));
                }
            });
        });
    }
}

function buildAdvancedArgsInput(script, placeholder) {
    return `
        <details class="script-advanced-args">
            <summary>Avanzado</summary>
            <input type="text" class="script-item-args-input" id="args-${script.name.replace(".", "-")}" ${placeholder}>
        </details>
    `;
}

function escapeHtml(value) {
    return String(value ?? "")
        .replaceAll("&", "&amp;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#039;");
}

function getScriptBranchOptions() {
    return branchesData
        .filter(b => b.type === "branch")
        .map(b => {
            const displayName = b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, "");
            return `
                <label class="script-location-option">
                    <input type="checkbox" value="${escapeHtml(b.location_id)}">
                    <span>${escapeHtml(displayName)}</span>
                </label>
            `;
        })
        .join("");
}

function buildScriptScopeControls(scriptName) {
    return `
        <div class="script-scope-panel" data-script="${escapeHtml(scriptName)}">
            <div class="script-panel-label">Alcance</div>
            <div class="script-scope-row">
                <label class="script-scope-choice">
                    <input type="radio" name="scope-${escapeHtml(scriptName)}" value="active" checked>
                    <span>Sucursal activa</span>
                </label>
                <label class="script-scope-choice">
                    <input type="radio" name="scope-${escapeHtml(scriptName)}" value="custom">
                    <span>Seleccionar sucursales</span>
                </label>
                <label class="script-scope-choice">
                    <input type="radio" name="scope-${escapeHtml(scriptName)}" value="location_id">
                    <span>Location ID</span>
                </label>
                <label class="script-scope-choice danger">
                    <input type="radio" name="scope-${escapeHtml(scriptName)}" value="all">
                    <span>Todas</span>
                </label>
            </div>
            <div class="script-location-id-row hidden">
                <input type="text" class="script-location-id-input" placeholder="Pega aquí el Location ID...">
            </div>
            <div class="script-location-picker hidden">
                <input type="text" class="script-location-search" placeholder="Buscar sucursal...">
                <div class="script-location-actions">
                    <button type="button" class="btn btn-secondary btn-sm btn-check-visible">Seleccionar visibles</button>
                    <button type="button" class="btn btn-secondary btn-sm btn-uncheck-all">Limpiar</button>
                </div>
                <div class="script-location-list">
                ${getScriptBranchOptions()}
                </div>
            </div>
            <div class="script-scope-help">Las sucursales seleccionadas se ejecutan en paralelo por defecto.</div>
            <div class="script-panel-label script-mode-label">Modo de ejecución</div>
            <div class="script-mode-row">
                <label class="script-mode-choice">
                    <input type="radio" name="mode-${escapeHtml(scriptName)}" value="sequential">
                    <span>Una por una</span>
                </label>
                <label class="script-mode-choice">
                    <input type="radio" name="mode-${escapeHtml(scriptName)}" value="parallel" checked>
                    <span>En paralelo</span>
                </label>
            </div>
        </div>
    `;
}

function initScriptScopeControls(card) {
    const scopeRadios = card.querySelectorAll(".script-scope-choice input");
    const locationPicker = card.querySelector(".script-location-picker");
    const locationIdRow = card.querySelector(".script-location-id-row");
    const locationSearch = card.querySelector(".script-location-search");
    const checkVisibleBtn = card.querySelector(".btn-check-visible");
    const uncheckAllBtn = card.querySelector(".btn-uncheck-all");
    if (!scopeRadios.length || !locationPicker) return;

    scopeRadios.forEach(radio => {
        radio.addEventListener("change", () => {
            const scope = card.querySelector(".script-scope-choice input:checked")?.value || "active";
            locationPicker.classList.toggle("hidden", scope !== "custom");
            locationIdRow?.classList.toggle("hidden", scope !== "location_id");
        });
    });

    locationSearch?.addEventListener("input", () => {
        const q = locationSearch.value.toLowerCase().trim();
        card.querySelectorAll(".script-location-option").forEach(option => {
            option.classList.toggle("hidden", !option.innerText.toLowerCase().includes(q));
        });
    });

    checkVisibleBtn?.addEventListener("click", () => {
        card.querySelectorAll(".script-location-option:not(.hidden) input").forEach(input => {
            input.checked = true;
        });
    });

    uncheckAllBtn?.addEventListener("click", () => {
        card.querySelectorAll(".script-location-option input").forEach(input => {
            input.checked = false;
        });
    });
}

function getScriptScopePayload(card, script) {
    if (!script.supports_locations) return {};

    const scope = card.querySelector(".script-scope-choice input:checked")?.value || "active";
    const executionMode = card.querySelector(".script-mode-choice input:checked")?.value || "parallel";

    if (scope === "all") {
        return { all_locations: true, execution_mode: executionMode };
    }

    if (scope === "active") {
        if (!activeBranchId || activeBranchId === "global") {
            showToast("Selecciona una sucursal activa o cambia el alcance a varias/todas.", "error");
            return null;
        }
        return { locations: [activeBranchId], execution_mode: executionMode };
    }

    if (scope === "location_id") {
        const locationId = card.querySelector(".script-location-id-input")?.value.trim();
        if (!locationId) {
            showToast("Pega un Location ID para ejecutar el script.", "error");
            return null;
        }
        return { locations: [locationId], execution_mode: executionMode };
    }

    const selected = Array.from(card.querySelectorAll(".script-location-option input:checked")).map(input => input.value);
    if (selected.length === 0) {
        showToast("Selecciona al menos una sucursal para ejecutar el script.", "error");
        return null;
    }
    return { locations: selected, execution_mode: executionMode };
}

function setTerminalState(state, label) {
    const term = document.querySelector(".terminal-card");
    const badge = document.getElementById("terminal-status-badge");
    if (!term || !badge) return;

    term.classList.remove("is-running", "is-success", "is-failed", "is-disconnected");
    badge.className = "terminal-status-badge " + state;
    badge.innerText = label;

    if (state !== "idle") {
        term.classList.add("is-" + state);
    }
}

// --- TERMINAL: TABS, REGISTRO DE ERRORES Y COPIA ---

window.terminalErrors = window.terminalErrors || [];
window.activeRunContext = window.activeRunContext || null;

function initTerminalTabs() {
    const tabs = document.querySelectorAll("#terminal-tabs .terminal-tab");
    if (!tabs.length) return;
    tabs.forEach(tab => {
        tab.addEventListener("click", () => {
            const target = tab.getAttribute("data-term-tab");
            switchTerminalTab(target);
        });
    });
}

function switchTerminalTab(tabName) {
    document.querySelectorAll("#terminal-tabs .terminal-tab").forEach(tab => {
        tab.classList.toggle("active", tab.getAttribute("data-term-tab") === tabName);
    });
    document.querySelectorAll(".terminal-card [data-term-panel]").forEach(panel => {
        const isActive = panel.getAttribute("data-term-panel") === tabName;
        panel.classList.toggle("hidden", !isActive);
    });
}

function getActiveTerminalTab() {
    const active = document.querySelector("#terminal-tabs .terminal-tab.active");
    return active ? active.getAttribute("data-term-tab") : "output";
}

function buildOutputCopyText() {
    // El buffer del LiveTerminal es la fuente de verdad. Leer del DOM forzaría
    // layout sobre cientos de nodos y duplicaría memoria. Caer al DOM solo si
    // la instancia no existe todavía (ej. nunca se inició un run).
    if (window.activeLiveTerminal && typeof window.activeLiveTerminal.getCopyText === "function") {
        return window.activeLiveTerminal.getCopyText();
    }
    const screen = document.getElementById("terminal-screen");
    if (!screen) return "";
    const lines = Array.from(screen.querySelectorAll(".terminal-line"));
    if (!lines.length) return "";
    return lines.map(l => l.innerText || l.textContent).join("\n");
}

function buildErrorsCopyText() {
    if (!window.terminalErrors.length) return "";
    return window.terminalErrors.map(err => {
        const time = err.timestamp ? `[${err.timestamp}] ` : "";
        return `${time}[${err.scriptName || "—"}] (task ${err.taskId || "—"}) ${err.text}`;
    }).join("\n");
}

function recordTerminalError(text) {
    if (!text) return;
    const ctx = window.activeRunContext || {};
    const entry = {
        scriptName: ctx.scriptName || "—",
        taskId: ctx.taskId || "—",
        text: String(text),
        timestamp: new Date().toLocaleTimeString("es-MX", { hour12: false })
    };
    window.terminalErrors.push(entry);
    appendErrorToPanel(entry);
    refreshErrorsBadge();
}

function appendErrorToPanel(entry) {
    const list = document.getElementById("terminal-errors-list");
    if (!list) return;

    // Limpiar placeholder en la primera entrada
    const placeholder = list.querySelector(".terminal-placeholder");
    if (placeholder) placeholder.remove();

    const item = document.createElement("div");
    item.className = "terminal-error-item";

    const header = document.createElement("div");
    header.className = "terminal-error-item-header";
    header.innerHTML = `
        <span class="err-script"><i class="fa-solid fa-file-code"></i> ${escapeHtml(entry.scriptName)}</span>
        <span class="err-task"><i class="fa-solid fa-fingerprint"></i> ${escapeHtml(entry.taskId)}</span>
        <span class="err-time">${escapeHtml(entry.timestamp)}</span>
    `;

    const body = document.createElement("div");
    body.className = "terminal-error-item-body";
    body.textContent = entry.text;

    item.appendChild(header);
    item.appendChild(body);
    list.appendChild(item);

    // Mantener el panel acotado para no saturar el DOM
    while (list.querySelectorAll(".terminal-error-item").length > 500) {
        const first = list.querySelector(".terminal-error-item");
        if (first) list.removeChild(first); else break;
    }

    list.scrollTop = list.scrollHeight;
}

function refreshErrorsBadge() {
    const count = window.terminalErrors.length;
    const countBadge = document.getElementById("terminal-error-count");
    const total = document.getElementById("terminal-errors-total");
    const breakdown = document.getElementById("terminal-errors-breakdown");
    const tabBtn = document.querySelector('#terminal-tabs .terminal-tab[data-term-tab="errors"]');

    if (countBadge) countBadge.textContent = count;
    if (total) total.textContent = count;
    if (tabBtn) tabBtn.classList.toggle("has-errors", count > 0);

    if (breakdown) {
        if (!count) {
            breakdown.textContent = "Sin scripts con fallos.";
        } else {
            const byScript = window.terminalErrors.reduce((acc, e) => {
                const key = e.scriptName || "—";
                acc[key] = (acc[key] || 0) + 1;
                return acc;
            }, {});
            const parts = Object.entries(byScript)
                .sort((a, b) => b[1] - a[1])
                .map(([name, n]) => `${name}: ${n}`);
            breakdown.textContent = parts.join(" · ");
        }
    }
}

function clearTerminalErrors() {
    window.terminalErrors = [];
    const list = document.getElementById("terminal-errors-list");
    if (list) {
        list.innerHTML = `
            <div class="terminal-placeholder">
                <i class="fa-solid fa-shield-halved"></i>
                <p>Sin errores registrados</p>
                <span>Los errores detectados durante la ejecución aparecerán aquí con su script, Task ID y log.</span>
            </div>
        `;
    }
    refreshErrorsBadge();
}

function escapeHtml(str) {
    return String(str)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
}

function setCardState(card, state, btnLabel) {
    if (!card) return;
    card.classList.remove("is-running", "is-success", "is-failed");
    const btn = card.querySelector(".btn-run-script");
    if (btn) {
        btn.disabled = (state === "running");
        btn.innerHTML = btnLabel;
    }
    if (state !== "idle") {
        card.classList.add("is-" + state);
    }
}

// ---------------------------------------------------------------------------
// LiveTerminal singleton — terminal de scripts. Se crea una vez y se reutiliza
// entre runs (un reset() limpia entre ejecuciones). El módulo live_terminal.js
// se incluye antes que app.js, así window.LiveTerminal siempre existe.
// ---------------------------------------------------------------------------
function ensureLiveTerminal() {
    if (window.activeLiveTerminal) return window.activeLiveTerminal;
    const screen = document.getElementById("terminal-screen");
    if (!screen || !window.LiveTerminal) return null;
    const term = new window.LiveTerminal(screen, {
        maxLines: 3000,
        onError: (text) => recordTerminalError(text),
        onStats: (stats) => updateRunCounter(stats),
        // onEof y onStreamError los seteamos dinámicamente en attachStream
        // según el run activo (ver executeScript).
    });
    // El badge "Volver al final" se muestra/oculta según userScrolledUp.
    // Como el flush ya hace el toggle, lo seguimos con un listener adicional
    // de scroll (passive) para reflejar al instante el estado del usuario.
    screen.addEventListener("scroll", () => {
        const badge = document.getElementById("terminal-resume-scroll");
        if (!badge) return;
        const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 40;
        if (atBottom) badge.classList.add("hidden");
        else if (window.activeEventSource) badge.classList.remove("hidden");
    }, { passive: true });
    window.activeLiveTerminal = term;
    return term;
}

function updateRunCounter(stats) {
    const el = document.getElementById("terminal-run-counter");
    if (!el || !stats) return;
    const fmt = (n) => n.toLocaleString("es-MX");
    if (stats.truncated > 0) {
        el.innerHTML = `${fmt(stats.visible)} líneas · <span class="truncated">${fmt(stats.truncated)} truncadas</span>`;
    } else {
        el.textContent = `${fmt(stats.visible)} líneas`;
    }
}

function setRunBarVisible(visible, taskId) {
    const bar = document.getElementById("terminal-run-bar");
    if (!bar) return;
    if (visible) {
        bar.classList.remove("hidden");
        const dl = document.getElementById("btn-download-log");
        if (dl) {
            dl.setAttribute("data-task-id", taskId || "");
            if (taskId) dl.classList.remove("hidden");
        }
    } else {
        bar.classList.add("hidden");
        const dl = document.getElementById("btn-download-log");
        if (dl) {
            dl.setAttribute("data-task-id", "");
            dl.classList.add("hidden");
        }
    }
}

// Monta los botones de Pausar/Reanudar/Detener/Revertir en el header sticky,
// fuera del screen del terminal. Así nunca se truncan con el cap del buffer.
function mountRunControls(taskId, supportsAudit) {
    const container = document.getElementById("terminal-run-controls");
    if (!container) return;
    // Limpiar controles previos sin innerHTML masivo
    while (container.firstChild) container.removeChild(container.firstChild);
    container.setAttribute("data-task-id", taskId || "");
    if (!supportsAudit || !taskId) return;

    const makeBtn = (cls, label, variant) => {
        const b = document.createElement("button");
        b.className = `btn btn-${variant || "secondary"} btn-sm ${cls}`;
        b.textContent = label;
        return b;
    };
    const pause = makeBtn("btn-run-pause", "Pausar");
    const resume = makeBtn("btn-run-resume", "Reanudar");
    const stop = makeBtn("btn-run-stop", "Detener seguro");
    const rollback = makeBtn("btn-run-rollback", "Revertir cambios", "primary");

    pause.addEventListener("click", () => controlRun(taskId, "pause"));
    resume.addEventListener("click", () => controlRun(taskId, "resume"));
    stop.addEventListener("click", () => controlRun(taskId, "stop"));
    rollback.addEventListener("click", async () => {
        const ok = await appConfirm({
            title: "Revertir ejecución",
            message: "Esto revertirá los cambios aplicados por esta ejecución (donde sea posible). Algunas operaciones pueden no ser reversibles.",
            confirmText: "Revertir",
            severity: "warning",
        });
        if (!ok) return;
        await controlRun(taskId, "rollback");
    });

    container.appendChild(pause);
    container.appendChild(resume);
    container.appendChild(stop);
    container.appendChild(rollback);
}

async function executeScript(scriptName, argumentsVal, scopePayload = {}, supportsAudit = false) {
    const locationCount = scopePayload.locations ? scopePayload.locations.length : 0;
    const scopeLabel = scopePayload.all_locations ? "todas las sucursales" : (locationCount > 1 ? `${locationCount} sucursales` : (locationCount === 1 ? "1 sucursal" : "alcance por argumentos"));

    const term = ensureLiveTerminal();
    if (!term) {
        showToast("No se pudo inicializar la terminal en vivo.", "error");
        return;
    }

    // Cerrar EventSource previo activo y limpiar buffer para el nuevo run.
    if (window.activeEventSource) {
        try { window.activeEventSource.close(); } catch (e) {}
        window.activeEventSource = null;
    }
    term.detach();
    term.reset();
    setRunBarVisible(false);

    const btn = document.querySelector(`.btn-run-script[data-name="${scriptName}"]`);
    const card = btn ? btn.closest(".script-item-card") : null;

    if (window.activeExecutingCard && window.activeExecutingCard !== card) {
        setCardState(window.activeExecutingCard, "idle", `<i class="fa-solid fa-play"></i> Ejecutar`);
    }
    window.activeExecutingCard = card;

    setTerminalState("running", "Ejecutando");
    setCardState(card, "running", `<i class="fa-solid fa-spinner fa-spin"></i> Ejecutando...`);

    // Contexto activo para que el panel de errores pueda etiquetar cada línea
    window.activeRunContext = { scriptName: scriptName, taskId: null };

    term.appendSystem(`[SISTEMA]: Inicializando subproceso para ${scriptName} (${scopeLabel})...`, "info");

    const sMeta = window.allScriptsData ? window.allScriptsData.find(s => s.name === scriptName) : null;
    if (sMeta && sMeta.mutator === true) {
        if (window.AppMode.isDryRun) {
            term.appendSystem("[SISTEMA]: MODO SIMULACIÓN (DRY-RUN) ACTIVO — Ningún cambio será guardado en Bucéfalo.", "info");
        } else {
            term.appendSystem("[SISTEMA ¡ATENCIÓN!]: MODO LIVE ACTIVO — Los cambios se aplicarán de verdad en Bucéfalo.", "error");
        }
    }

    try {
        const response = await mutateFetch(`/api/scripts/${scriptName}/run`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                arguments: argumentsVal,
                locations: scopePayload.locations || [],
                all_locations: Boolean(scopePayload.all_locations),
                execution_mode: scopePayload.execution_mode || "sequential"
            })
        });
        const data = await response.json();

        if (!data.success) {
            term.appendSystem(`[SISTEMA ERROR]: No se pudo iniciar. ${data.message}`, "error");
            setTerminalState("failed", "Falló");
            setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> Error inicio`);
            return;
        }

        const taskId = data.task_id;
        if (window.activeRunContext) window.activeRunContext.taskId = taskId;
        term.appendSystem(`[SISTEMA]: Script iniciado. Conectando logs vía SSE (Task ID: ${taskId})...`, "info");

        // Mostrar el bar sticky con contador + controles + botón descargar
        setRunBarVisible(true, taskId);
        mountRunControls(taskId, supportsAudit);

        let streamCompleted = false;
        let streamErrorCount = 0;

        const finishWithStatus = async () => {
            if (streamCompleted) return;
            streamCompleted = true;
            loadSyncHistory();
            // Pequeña espera para que el backend cierre el run en su tabla.
            setTimeout(async () => {
                let isSuccess = true;
                try {
                    const statusResponse = await fetch(`/api/scripts/status/${taskId}`);
                    if (statusResponse.ok) {
                        const status = await statusResponse.json();
                        isSuccess = (status.status === "success");
                    }
                } catch (e) { /* fallback */ }
                if (isSuccess) {
                    setTerminalState("success", "Completado");
                    setCardState(card, "success", `<i class="fa-solid fa-check"></i> Completado`);
                    showToast(`Script ${scriptName} finalizado con éxito.`, "success");
                } else {
                    setTerminalState("failed", "Falló");
                    setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> Falló`);
                    showToast(`Script ${scriptName} finalizado con errores.`, "error");
                }
            }, 100);
        };

        // attachStream se encarga del EventSource, batching y rendering.
        const eventSource = term.attachStream(taskId);
        window.activeEventSource = eventSource;

        // onEof reasignado para este run específico.
        term.onEof = () => {
            term.appendSystem("[SISTEMA]: Logs cerrados. Ejecución finalizada.", "info");
            window.activeEventSource = null;
            finishWithStatus();
        };

        // onStreamError: reintentar mientras EventSource está reconectando;
        // si el backend ya marcó success/failed, cerramos el run.
        term.onStreamError = async () => {
            if (streamCompleted || eventSource.readyState === EventSource.CLOSED) return;
            streamErrorCount += 1;
            try {
                const statusResponse = await fetch(`/api/scripts/status/${taskId}`);
                if (statusResponse.ok) {
                    const status = await statusResponse.json();
                    if (status.status === "success" || status.status === "failed") {
                        const lineClass = status.status === "success" ? "success" : "error";
                        term.appendSystem(`[SISTEMA]: Stream cerrado. Estado final: ${status.status}.`, lineClass);
                        term.detach();
                        window.activeEventSource = null;
                        streamCompleted = true;
                        if (status.status === "success") {
                            setTerminalState("success", "Completado");
                            setCardState(card, "success", `<i class="fa-solid fa-check"></i> Completado`);
                            showToast(`Script ${scriptName} finalizado con éxito.`, "success");
                        } else {
                            setTerminalState("failed", "Falló");
                            setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> Falló`);
                            showToast(`Script ${scriptName} finalizado con errores.`, "error");
                        }
                        return;
                    }
                }
            } catch (e) { /* dejar reintentar */ }
            if (streamErrorCount < 4) return;
            term.detach();
            window.activeEventSource = null;
            term.appendSystem("[SISTEMA ERROR]: Conexión con EventSource perdida de forma inesperada.", "error");
            setTerminalState("disconnected", "Desconectado");
            setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> Conexión perdida`);
        };

    } catch (e) {
        term.appendSystem("[SISTEMA ERROR]: Fallo de conexión con la API del servidor.", "error");
        showToast("Error de red intentando ejecutar script.", "error");
        setTerminalState("failed", "Falló");
        setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> Error red`);
    }
}

async function controlRun(taskId, action) {
    const term = ensureLiveTerminal();
    try {
        const response = await mutateFetch(`/api/scripts/runs/${taskId}/${action}`, { method: "POST" });
        const data = await response.json();
        if (!response.ok || data.success === false) {
            throw new Error(data.detail || data.message || "No se pudo aplicar la acción");
        }
        if (term) term.appendSystem(`[SISTEMA]: ${data.message || action + " solicitado"}`, "info");
        showToast(data.message || `${action} solicitado`, "success");
    } catch (e) {
        if (term) term.appendSystem(`[SISTEMA ERROR]: ${e.message}`, "error");
        showToast(e.message, "error");
    }
}

// --- CONTROLES DE SINCRONIZACIÓN DE LA API ---

async function triggerActiveBranchSync() {
    const btn = document.getElementById("btn-sync-active");
    const originalText = btn.innerHTML;
    
    btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Sincronizando...`;
    btn.disabled = true;
    
    showToast("Iniciando sincronización de sucursal en segundo plano...", "info");
    
    try {
        const response = await fetch(`/api/sync/${activeBranchId}`, { method: "POST" });
        const data = await response.json();
        
        showToast(data.message, "success");
        
        // Encender polling corto local para esta sucursal (por simplicidad, recargamos en 10s o cuando veamos cambios)
        setTimeout(async () => {
            await loadBranches();
            selectBranch(activeBranchId);
            showToast("Datos de la sucursal actualizados con éxito.", "success");
            btn.innerHTML = originalText;
            btn.disabled = false;
        }, 12000);
        
    } catch (e) {
        showToast("No se pudo iniciar la sincronización de la sucursal.", "error");
        btn.innerHTML = originalText;
        btn.disabled = false;
    }
}

async function triggerGlobalSync() {
    if (isSyncingGlobally) return;
    
    try {
        const response = await fetch("/api/sync", { method: "POST" });
        const data = await response.json();
        
        if (data.error) {
            showToast(data.error, "error");
            return;
        }
        
        showToast(data.message, "success");
        startGlobalSyncPolling();
    } catch (e) {
        showToast("Error iniciando sincronización global.", "error");
    }
}

async function triggerMetadataSync() {
    if (isSyncingGlobally) return;
    
    try {
        const response = await fetch("/api/sync/metadata", { method: "POST" });
        const data = await response.json();
        
        if (data.error) {
            showToast(data.error, "error");
            return;
        }
        
        showToast(data.message, "success");
        startGlobalSyncPolling();
    } catch (e) {
        showToast("Error iniciando sincronización de metadata.", "error");
    }
}

function startGlobalSyncPolling() {
    isSyncingGlobally = true;
    document.getElementById("btn-sync-global").disabled = true;
    const syncMenuBtn = document.getElementById("btn-sync-menu");
    const syncMetadataBtn = document.getElementById("btn-sync-metadata");
    if (syncMenuBtn) syncMenuBtn.disabled = true;
    if (syncMetadataBtn) syncMetadataBtn.disabled = true;
    document.getElementById("sync-status-bar").classList.remove("hidden");
    
    syncProgressInterval = setInterval(async () => {
        try {
            const response = await fetch("/api/sync/progress");
            const progress = await response.json();
            
            const total = progress.total || 1;
            const done = progress.done || 0;
            const pct = ((done / total) * 100).toFixed(0);
            const workers = progress.max_workers ? ` | workers: ${progress.max_workers}` : "";
            
            document.getElementById("sync-progress-fill").style.width = `${pct}%`;
            document.getElementById("sync-progress-text").innerText = `Sync: ${done}/${total} (${progress.current_branch})${workers}`;
            
            if (!progress.is_running) {
                clearInterval(syncProgressInterval);
                isSyncingGlobally = false;
                document.getElementById("btn-sync-global").disabled = false;
                if (syncMenuBtn) syncMenuBtn.disabled = false;
                if (syncMetadataBtn) syncMetadataBtn.disabled = false;
                document.getElementById("sync-status-bar").classList.add("hidden");
                showToast("Sincronización global completada con éxito.", "success");
                
                // Recargar todo
                await loadBranches();
                selectBranch(activeBranchId);
            }
        } catch (e) {
            console.error("Error polling progress", e);
        }
    }, 3000);
}

async function checkGlobalSyncProgress() {
    try {
        const response = await fetch("/api/sync/progress");
        const progress = await response.json();
        if (progress.is_running) {
            startGlobalSyncPolling();
        }
    } catch (e) {
        console.error("Error checking sync progress", e);
    }
}

// --- UTILERÍAS ---

function formatCurrency(value) {
    const val = parseFloat(value) || 0.0;
    return new Intl.NumberFormat("es-MX", {
        style: "currency",
        currency: "MXN"
    }).format(val);
}

// --- ACCIONES GLOBALES Y TABLA MASTER ---

function renderGlobalMasterTable(filterQuery = "") {
    const tbody = document.getElementById("global-master-rows");
    if (!tbody) return;
    
    tbody.innerHTML = "";
    const q = filterQuery.toLowerCase().trim();
    
    // Filtrar cuentas (sucursales y marca principal)
    const filtered = branchesData.filter(b => {
        const nameMatch = b.nombre.toLowerCase().includes(q);
        const idMatch = b.location_id.toLowerCase().includes(q);
        return nameMatch || idMatch;
    });
    
    if (filtered.length === 0) {
        tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 24px;">No se encontraron sucursales que coincidan con la búsqueda.</td></tr>`;
        return;
    }
    
    filtered.forEach((b, index) => {
        const m = b.metrics || { contacts_count: 0, contacts_without_opp_count: 0, opps_count: 0, opps_by_status: { won: { value: 0 }, open: { value: 0 } } };

        let displayNum = "-";
        let displayName = b.nombre;
        const match = b.nombre.match(/^(\d+)\s*-\s*MP\s*-\s*(.*)$/);
        if (match) {
            displayNum = match[1];
            displayName = match[2];
        } else if (b.type === "brand") {
            displayNum = "★";
            displayName = b.nombre;
        }

        const wonVal = m.opps_by_status?.won?.value || 0;
        const openVal = m.opps_by_status?.open?.value || 0;
        const noOppCount = m.contacts_without_opp_count || 0;
        const noOppPct = m.contacts_count > 0 ? Math.round((noOppCount / m.contacts_count) * 100) : 0;
        let noOppCellHTML;
        if (noOppCount === 0) {
            noOppCellHTML = `<span class="badge badge-success" title="Todos los contactos tienen al menos una oportunidad."><i class="fa-solid fa-circle-check"></i> 0</span>`;
        } else {
            const sev = noOppPct >= 20 ? "danger" : "warning";
            noOppCellHTML = `<button class="badge badge-${sev} btn-view-no-opp" data-id="${b.location_id}" title="Clic para ver los ${noOppCount} contactos sin oportunidad (${noOppPct}% del total)." style="border: none; cursor: pointer; font-family: inherit;"><i class="fa-solid fa-triangle-exclamation"></i> ${noOppCount} <span style="opacity: 0.75; font-weight: 500;">(${noOppPct}%)</span></button>`;
        }
        
        // Formatear estado de sync
        let syncStatusHTML = '<span class="badge badge-secondary">N/A</span>';
        if (m.last_sync) {
            const status = m.last_sync.status;
            const time = m.last_sync.finished_at ? m.last_sync.finished_at.substring(11, 16) : (m.last_sync.started_at ? m.last_sync.started_at.substring(11, 16) : "");
            const date = m.last_sync.finished_at ? m.last_sync.finished_at.substring(5, 10) : (m.last_sync.started_at ? m.last_sync.started_at.substring(5, 10) : "");
            
            if (status === "success") {
                syncStatusHTML = `<span class="badge badge-success" title="Último éxito: ${m.last_sync.finished_at}"><i class="fa-solid fa-circle-check"></i> Éxito (${date} ${time})</span>`;
            } else if (status === "failed") {
                syncStatusHTML = `<span class="badge badge-danger" title="Error: ${m.last_sync.error_message || 'Desconocido'}\nFecha: ${m.last_sync.finished_at || m.last_sync.started_at}"><i class="fa-solid fa-circle-xmark"></i> Falló</span>`;
            } else if (status === "running") {
                syncStatusHTML = `<span class="badge badge-warning"><i class="fa-solid fa-arrows-rotate fa-spin"></i> Sync...</span>`;
            }
        }
        
        const tr = document.createElement("tr");
        if (b.type === "brand") {
            tr.style.background = "rgba(124, 58, 237, 0.04)";
            tr.style.borderLeft = "3px solid var(--color-primary)";
        }
        
        tr.innerHTML = `
            <td><strong style="color: var(--text-muted);">${displayNum}</strong></td>
            <td>
                <div style="font-weight: 600; color: var(--text-primary);"><a href="https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(b.location_id)}/dashboard" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir ${displayName} en Bucéfalo">${displayName}</a></div>
                <div style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono);">${b.location_id.substring(0, 12)}...</div>
            </td>
            <td><i class="fa-solid fa-user" style="color: var(--color-primary); margin-right: 4px; font-size: 12px;"></i> <strong>${m.contacts_count}</strong></td>
            <td>${noOppCellHTML}</td>
            <td><i class="fa-solid fa-briefcase" style="color: var(--text-muted); margin-right: 4px; font-size: 12px;"></i> ${m.opps_count}</td>
            <td><a href="https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(b.location_id)}/opportunities/pipeline" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir Pipelines de ${displayName} en Bucéfalo"><i class="fa-solid fa-diagram-project" style="color: var(--color-primary); margin-right: 4px; font-size: 12px;"></i> <strong>${m.pipelines_count || 0}</strong></a></td>
            <td><strong style="color: var(--color-success);">${formatCurrency(wonVal)}</strong></td>
            <td><strong style="color: var(--color-warning);">${formatCurrency(openVal)}</strong></td>
            <td>${syncStatusHTML}</td>
            <td style="text-align: center;">
                <div style="display: flex; gap: 6px; justify-content: center;">
                    <button class="btn btn-secondary btn-sm btn-view-branch" data-id="${b.location_id}" title="Ver Dashboard de Sucursal" style="padding: 6px 10px;">
                        <i class="fa-solid fa-eye"></i> Ver
                    </button>
                    <button class="btn btn-primary btn-sm btn-sync-branch-inline" data-id="${b.location_id}" title="Sincronizar Sucursal" style="padding: 6px 10px;">
                        <i class="fa-solid fa-arrows-rotate"></i>
                    </button>
                </div>
            </td>
        `;

        // Event listeners para botones
        tr.querySelector(".btn-view-branch").addEventListener("click", () => {
            selectBranch(b.location_id);
        });

        const noOppBtn = tr.querySelector(".btn-view-no-opp");
        if (noOppBtn) {
            noOppBtn.addEventListener("click", (e) => {
                e.stopPropagation();
                openBranchWithoutOppView(b.location_id);
            });
        }

        tr.querySelector(".btn-sync-branch-inline").addEventListener("click", (e) => {
            triggerBranchSyncInline(b.location_id, e.currentTarget);
        });

        tbody.appendChild(tr);
    });
}

/**
 * Abre la sucursal indicada, salta a la pestaña Contactos y activa el filtro
 * "solo sin oportunidad". Es el destino de los badges naranjas del Master.
 */
function openBranchWithoutOppView(locationId) {
    // 1) Cambiar sucursal (esto resetea el filtro a false internamente).
    selectBranch(locationId);
    // 2) Re-activar el filtro después del reset implícito.
    contactsWithoutOppFilter = true;
    currentContactsPage = 1;
    // 3) Saltar a la pestaña Contactos (su click dispara loadContactsTable
    //    que ya leerá el filtro activo).
    const contactsTab = document.querySelector('.tab-link[data-tab="tab-contacts"]');
    if (contactsTab) {
        contactsTab.click();
    } else {
        loadContactsTable();
    }
}

async function triggerBranchSyncInline(locationId, btn) {
    if (btn.disabled) return;
    
    const originalHTML = btn.innerHTML;
    btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i>`;
    btn.disabled = true;
    
    const b = branchesData.find(x => x.location_id === locationId);
    const branchName = b ? b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, "") : locationId;
    
    showToast(`Iniciando sincronización de ${branchName}...`, "info");
    
    try {
        const response = await fetch(`/api/sync/${locationId}`, { method: "POST" });
        const data = await response.json();
        
        showToast(data.message, "success");
        
        // Encender polling corto para esta sucursal (12 segundos)
        setTimeout(async () => {
            await loadBranches();
            // Si el branch sigue seleccionado en el master, refresca la tabla master
            if (activeBranchId === "global") {
                renderGlobalMasterTable(document.getElementById("global-master-search")?.value || "");
                // También actualizar sidebar
                renderBranchesSidebar();
            } else if (activeBranchId === locationId) {
                selectBranch(activeBranchId);
            }
            showToast(`Sincronización de ${branchName} finalizada con éxito.`, "success");
            btn.innerHTML = originalHTML;
            btn.disabled = false;
        }, 12000);
        
    } catch (e) {
        showToast(`No se pudo iniciar la sincronización de ${branchName}.`, "error");
        btn.innerHTML = originalHTML;
        btn.disabled = false;
    }
}

// ==========================================================================
// WORKFLOWS CONTROLLER
// ==========================================================================

let workflowsData = [];
// Mapa: workflowKey -> {workflow_id, location_id, current_status, name, account_name}
// workflowKey = `${location_id}:${workflow_id}` (un workflow_id puede repetirse entre sucursales)
const selectedWorkflows = new Map();
function workflowKey(locationId, workflowId) { return `${locationId}:${workflowId}`; }
let workflowSort = { field: null, direction: "asc" };
const WORKFLOW_DATE_FIELDS = new Set(["created_at", "updated_at", "synced_at"]);

function onWorkflowHeaderSortClick(event) {
    const th = event.currentTarget;
    const field = th.dataset.sortField;
    if (!field) return;
    if (workflowSort.field === field) {
        workflowSort.direction = workflowSort.direction === "asc" ? "desc" : "asc";
    } else {
        workflowSort.field = field;
        workflowSort.direction = "asc";
    }
    renderWorkflowsTable();
}

function updateWorkflowSortIndicators() {
    document.querySelectorAll("#tab-workflows .workflow-sortable").forEach(th => {
        th.classList.remove("sort-asc", "sort-desc");
        if (th.dataset.sortField === workflowSort.field) {
            th.classList.add(workflowSort.direction === "asc" ? "sort-asc" : "sort-desc");
        }
    });
}

async function loadWorkflowsTable() {
    const tableRows = document.getElementById("workflows-table-rows");
    if (!tableRows) return;

    document.querySelectorAll("#tab-workflows .workflow-sortable").forEach(th => {
        if (th.dataset.sortBound) return;
        th.addEventListener("click", onWorkflowHeaderSortClick);
        th.dataset.sortBound = "1";
    });

    tableRows.innerHTML = `
        <tr>
            <td colspan="10" class="text-center">
                <i class="fa-solid fa-spinner fa-spin"></i> Cargando workflows...
            </td>
        </tr>
    `;

    try {
        let url = "/api/workflows";
        if (activeBranchId !== "global") {
            url += `?location_id=${activeBranchId}`;
        }
        
        const response = await fetch(url);
        if (!response.ok) throw new Error("Error fetching workflows");
        
        workflowsData = await response.json();

        populateWorkflowsBranchSelect();
        renderWorkflowsTable();
        updateBucefaloSessionIndicator();
        
    } catch (error) {
        console.error("Error loading workflows:", error);
        tableRows.innerHTML = `
            <tr>
                <td colspan="10" class="text-center text-danger">
                    <i class="fa-solid fa-triangle-exclamation"></i> Error al cargar workflows: ${error.message}
                </td>
            </tr>
        `;
    }
}

function populateWorkflowsBranchSelect() {
    const select = document.getElementById("workflow-branch-select");
    if (!select) return;
    
    select.innerHTML = '<option value="all">-- Todas las sucursales --</option>';
    
    if (activeBranchId === "global") {
        select.disabled = false;
        
        const uniqueBranches = {};
        workflowsData.forEach(w => {
            if (w.location_id && w.account_name) {
                uniqueBranches[w.location_id] = w.account_name;
            }
        });
        
        Object.keys(uniqueBranches).sort((a,b) => uniqueBranches[a].localeCompare(uniqueBranches[b])).forEach(locId => {
            const opt = document.createElement("option");
            opt.value = locId;
            opt.innerText = uniqueBranches[locId];
            select.appendChild(opt);
        });
    } else {
        select.disabled = true;
        const b = branchesData.find(x => x.location_id === activeBranchId);
        if (b) {
            select.innerHTML = `<option value="${activeBranchId}">${b.nombre}</option>`;
        }
    }
}

function renderWorkflowsTable() {
    const tableRows = document.getElementById("workflows-table-rows");
    if (!tableRows) return;
    
    const branchFilter = document.getElementById("workflow-branch-select")?.value || "all";
    const statusFilter = document.getElementById("workflow-status-select")?.value || "all";
    const searchQuery = (document.getElementById("workflow-table-search")?.value || "").toLowerCase().trim();
    const dateField = document.getElementById("workflow-date-field")?.value || "created_at";
    const dateAfterRaw = document.getElementById("workflow-date-after")?.value || "";
    const dateBeforeRaw = document.getElementById("workflow-date-before")?.value || "";
    const dateAfterTs = dateAfterRaw ? new Date(dateAfterRaw).getTime() : null;
    const dateBeforeTs = dateBeforeRaw ? new Date(dateBeforeRaw).getTime() : null;

    const filtered = workflowsData.filter(w => {
        if (activeBranchId === "global") {
            if (branchFilter !== "all" && w.location_id !== branchFilter) return false;
        }
        if (statusFilter !== "all") {
            if (statusFilter === "active" && w.status !== "active" && w.status !== "published") return false;
            if (statusFilter === "inactive" && w.status !== "inactive") return false;
            if (statusFilter === "draft" && w.status !== "draft") return false;
        }
        if (searchQuery) {
            const nameMatch = w.name?.toLowerCase().includes(searchQuery);
            const idMatch = w.id?.toLowerCase().includes(searchQuery);
            const triggerMatch = w.trigger?.toLowerCase().includes(searchQuery);
            if (!nameMatch && !idMatch && !triggerMatch) return false;
        }
        if (dateAfterTs !== null || dateBeforeTs !== null) {
            const raw = w[dateField];
            if (!raw) return false;
            const ts = new Date(raw).getTime();
            if (Number.isNaN(ts)) return false;
            if (dateAfterTs !== null && ts < dateAfterTs) return false;
            if (dateBeforeTs !== null && ts > dateBeforeTs) return false;
        }
        return true;
    });

    if (workflowSort.field) {
        const field = workflowSort.field;
        const dir = workflowSort.direction === "desc" ? -1 : 1;
        const isDate = WORKFLOW_DATE_FIELDS.has(field);
        filtered.sort((a, b) => {
            const av = a[field];
            const bv = b[field];
            const aEmpty = av === null || av === undefined || av === "";
            const bEmpty = bv === null || bv === undefined || bv === "";
            if (aEmpty && bEmpty) return 0;
            if (aEmpty) return 1;  // vacíos siempre al final
            if (bEmpty) return -1;
            if (isDate) {
                const at = new Date(av).getTime();
                const bt = new Date(bv).getTime();
                if (Number.isNaN(at) && Number.isNaN(bt)) return 0;
                if (Number.isNaN(at)) return 1;
                if (Number.isNaN(bt)) return -1;
                return (at - bt) * dir;
            }
            return String(av).localeCompare(String(bv), "es", { numeric: true, sensitivity: "base" }) * dir;
        });
    }
    updateWorkflowSortIndicators();

    if (filtered.length === 0) {
        tableRows.innerHTML = `
            <tr>
                <td colspan="10" class="text-center text-muted">
                    No se encontraron workflows con los filtros seleccionados.
                </td>
            </tr>
        `;
        return;
    }
    
    tableRows.innerHTML = filtered.map(w => {
        let statusBadgeClass = "badge-status-draft";
        let statusLabel = "Borrador";
        
        if (w.status === "active" || w.status === "published") {
            statusBadgeClass = "badge-status-active";
            statusLabel = "Activo";
        } else if (w.status === "inactive") {
            statusBadgeClass = "badge-status-inactive";
            statusLabel = "Pausado";
        }
        
        const triggerLabel = w.trigger || '<span class="text-secondary">-</span>';
        const dateFormatted = w.created_at ? new Date(w.created_at).toLocaleString('es-ES') : '<span class="text-secondary">-</span>';
        const updatedFormatted = w.updated_at ? new Date(w.updated_at).toLocaleString('es-ES') : '<span class="text-secondary">-</span>';
        const syncedFormatted = w.synced_at ? new Date(w.synced_at).toLocaleString('es-ES') : '<span class="text-secondary">-</span>';
        
        const isPublished = w.status === "active" || w.status === "published";
        const toggleIcon = isPublished ? "fa-toggle-on text-success" : "fa-toggle-off text-muted";
        const toggleTitle = isPublished ? "Pausar Workflow (Draft)" : "Activar Workflow (Publish)";

        const key = workflowKey(w.location_id, w.id);
        const isChecked = selectedWorkflows.has(key) ? "checked" : "";
        return `
            <tr>
                <td style="text-align:center;">
                    <input type="checkbox" class="workflow-row-check" data-key="${key}" data-id="${w.id}" data-location="${w.location_id}" data-status="${w.status}" data-name="${(w.name || '').replace(/"/g, '&quot;')}" data-account="${(w.account_name || '').replace(/"/g, '&quot;')}" ${isChecked}>
                </td>
                <td><a href="https://crm.bucefalocrm.io/v2/location/${w.location_id}/automation/workflows?listTab=all&tab=recent" target="_blank" rel="noopener noreferrer" title="Abrir directorio de workflows en Bucéfalo" style="color: #60A5FA; text-decoration: none; font-weight: bold;">${w.account_name || w.location_id}</a></td>
                <td><code>${w.id}</code></td>
                <td>${w.name}${(() => {
                    const cache = window.__workflowAnomaliesCache || {};
                    const k = `${w.location_id}::${w.id}`;
                    const n = cache[k];
                    if (n === undefined) return "";
                    if (n === 0) return ` <span class="badge" style="background: rgba(52, 211, 153, 0.18); color:#34D399; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:bold; margin-left:6px;" title="Sin anomalías detectadas">✓ limpio</span>`;
                    return ` <span class="badge" style="background: rgba(245, 158, 11, 0.18); color:#F59E0B; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:bold; margin-left:6px;" title="${n} anomalía(s) detectada(s) en el último escaneo">⚠ ${n} anomalía(s)</span>`;
                })()}</td>
                <td><span class="badge ${statusBadgeClass}">${statusLabel}</span></td>
                <td><code>${triggerLabel}</code></td>
                <td>${dateFormatted}</td>
                <td>${updatedFormatted}</td>
                <td>${syncedFormatted}</td>
                <td style="text-align: center;">
                    <div style="display: flex; justify-content: center; gap: 12px; align-items: center;">
                        <a class="btn-icon" title="Abrir en Bucéfalo" href="https://crm.bucefalocrm.io/location/${w.location_id}/workflow/${w.id}" target="_blank" rel="noopener noreferrer" style="background:none; border:none; cursor:pointer; text-decoration:none;">
                            <i class="fa-solid fa-up-right-from-square" style="font-size: 14px; color: #60A5FA;"></i>
                        </a>
                        <button class="btn-icon" title="${toggleTitle}" onclick="toggleWorkflowState('${w.id}', '${w.location_id}', '${w.status}')" style="background:none; border:none; cursor:pointer;">
                            <i class="fa-solid ${toggleIcon}" style="font-size: 18px;"></i>
                        </button>
                        <button class="btn-icon" title="Escanear anomalías en los nodos" onclick="scanWorkflowAnomalies('${w.id}', '${w.location_id}', '${w.name.replace(/'/g, "\\'")}', '${(w.account_name || '').replace(/'/g, "\\'")}')" style="background:none; border:none; cursor:pointer;">
                            <i class="fa-solid fa-stethoscope" style="font-size: 14px; color:#F59E0B;"></i>
                        </button>
                        <button class="btn-icon" title="Renombrar" onclick="openRenameWorkflowModal('${w.id}', '${w.location_id}', '${w.name.replace(/'/g, "\\'")}')" style="background:none; border:none; cursor:pointer;">
                            <i class="fa-solid fa-pen-to-square text-primary" style="font-size: 14px;"></i>
                        </button>
                        <button class="btn-icon" title="Eliminar" onclick="deleteWorkflow('${w.id}', '${w.location_id}', '${w.name.replace(/'/g, "\\'")}')" style="background:none; border:none; cursor:pointer;">
                            <i class="fa-solid fa-trash-can text-danger" style="font-size: 14px;"></i>
                        </button>
                    </div>
                </td>
            </tr>
        `;
    }).join("");

    // Bindear los checkboxes recién renderizados al estado de selección.
    document.querySelectorAll(".workflow-row-check").forEach(cb => {
        cb.addEventListener("change", onWorkflowRowCheckChange);
    });
    updateBulkBarUI();
}

function onWorkflowRowCheckChange(e) {
    const cb = e.target;
    const key = cb.dataset.key;
    if (cb.checked) {
        selectedWorkflows.set(key, {
            workflow_id: cb.dataset.id,
            location_id: cb.dataset.location,
            current_status: cb.dataset.status,
            name: cb.dataset.name,
            account_name: cb.dataset.account,
        });
    } else {
        selectedWorkflows.delete(key);
    }
    updateBulkBarUI();
}

function updateBulkBarUI() {
    const bar = document.getElementById("workflows-bulk-bar");
    const counter = document.getElementById("workflows-bulk-count");
    if (!bar || !counter) return;
    const inProgress = bulkProgressState !== null;
    const n = selectedWorkflows.size;
    if (inProgress) {
        // En modo progreso, la barra queda visible aunque no haya selección.
        bar.classList.remove("hidden");
    } else if (n > 0) {
        bar.classList.remove("hidden");
        counter.textContent = `${n} ${n === 1 ? "seleccionado" : "seleccionados"}`;
    } else {
        bar.classList.add("hidden");
    }
    // Sincronizar el checkbox maestro: marcado si TODAS las filas visibles están seleccionadas.
    const master = document.getElementById("workflows-select-all");
    const rowChecks = document.querySelectorAll(".workflow-row-check");
    if (master && rowChecks.length > 0) {
        const total = rowChecks.length;
        const checked = Array.from(rowChecks).filter(c => c.checked).length;
        master.checked = checked === total;
        master.indeterminate = checked > 0 && checked < total;
    } else if (master) {
        master.checked = false;
        master.indeterminate = false;
    }
}

function clearWorkflowSelection() {
    selectedWorkflows.clear();
    document.querySelectorAll(".workflow-row-check").forEach(cb => { cb.checked = false; });
    updateBulkBarUI();
}

function toggleWorkflowsSelectAll(e) {
    const checked = e.target.checked;
    document.querySelectorAll(".workflow-row-check").forEach(cb => {
        if (cb.checked !== checked) {
            cb.checked = checked;
            // Disparar el handler para que actualice el Map.
            cb.dispatchEvent(new Event("change"));
        }
    });
}

// ---------- Estado del progreso del bulk ----------
let bulkProgressState = null;
let bulkProgressEventSource = null;

function bulkEnterProgressMode(targetLabel) {
    // Oculta la fila de botones y muestra la sección de progreso.
    document.getElementById("workflows-bulk-actions").classList.add("hidden");
    const progress = document.getElementById("workflows-bulk-progress");
    progress.classList.remove("hidden");
    progress.style.display = "flex";
    document.getElementById("workflows-bulk-bar").classList.remove("hidden");

    document.getElementById("bulk-progress-title").innerHTML =
        `<i class="fa-solid fa-spinner fa-spin"></i> Procesando: <strong>${targetLabel}</strong>`;
    document.getElementById("bulk-progress-counter").textContent = "0/0";
    document.getElementById("bulk-progress-fill").style.width = "0%";
    document.getElementById("bulk-progress-current").textContent = "Iniciando…";
    document.getElementById("bulk-progress-summary").classList.add("hidden");
    // Mostrar botón "Cancelar" mientras el escaneo está activo.
    const cancelBtn = document.getElementById("btn-bulk-cancel");
    if (cancelBtn && bulkProgressState && bulkProgressState.taskId) {
        cancelBtn.classList.remove("hidden");
    } else if (cancelBtn) {
        cancelBtn.classList.add("hidden");
    }
}

function bulkExitProgressMode() {
    document.getElementById("workflows-bulk-actions").classList.remove("hidden");
    const progress = document.getElementById("workflows-bulk-progress");
    progress.classList.add("hidden");
    progress.style.display = "none";
    document.getElementById("bulk-progress-summary").classList.add("hidden");
    const retryBtn = document.getElementById("btn-bulk-retry-pending");
    if (retryBtn) retryBtn.classList.add("hidden");
    if (bulkProgressEventSource) {
        bulkProgressEventSource.close();
        bulkProgressEventSource = null;
    }
    bulkProgressState = null;
}

function bulkRenderProgress() {
    if (!bulkProgressState) return;
    const st = bulkProgressState;
    const total = st.total || 0;
    const done = st.done || 0;
    const pct = total > 0 ? Math.round((done / total) * 100) : 0;
    document.getElementById("bulk-progress-counter").textContent = `${done}/${total}`;
    document.getElementById("bulk-progress-fill").style.width = `${pct}%`;

    const isScanAnomalies = st.target === "scan-anomalies";
    const totalAnoms = (st.findings || []).length;
    const workflowsWithAnoms = Object.keys(st.anomaliesPerWorkflow || {}).filter(k => st.anomaliesPerWorkflow[k] > 0).length;

    if (st.finished) {
        if (st.sessionExpired) {
            document.getElementById("bulk-progress-title").innerHTML =
                `<i class="fa-solid fa-triangle-exclamation" style="color:#FBBF24;"></i> Lote interrumpido — sesión Bucéfalo expirada`;
            document.getElementById("bulk-progress-current").innerHTML =
                `<strong style="color:#FBBF24;">Acción requerida:</strong> renueva la sesión arriba y dale a "Reintentar pendientes".`;
        } else if (isScanAnomalies && totalAnoms > 0) {
            document.getElementById("bulk-progress-title").innerHTML =
                `<i class="fa-solid fa-triangle-exclamation" style="color:#F59E0B;"></i> <span style="color:#F59E0B;">${totalAnoms} anomalía(s) detectada(s)</span> en ${workflowsWithAnoms} workflow(s)`;
            document.getElementById("bulk-progress-current").innerHTML =
                `<span style="color:#FBBF24;">Revisa el detalle abajo y abre cada workflow para corregir.</span>`;
        } else if (isScanAnomalies) {
            document.getElementById("bulk-progress-title").innerHTML =
                `<i class="fa-solid fa-circle-check" style="color:#34D399;"></i> Escaneo completo — sin anomalías detectadas`;
            document.getElementById("bulk-progress-current").textContent = "Todos los workflows escaneados están limpios.";
        } else {
            document.getElementById("bulk-progress-title").innerHTML =
                `<i class="fa-solid fa-circle-check" style="color:#34D399;"></i> Procesamiento completado`;
            document.getElementById("bulk-progress-current").textContent =
                "Tabla actualizándose…";
        }
        document.getElementById("bulk-progress-summary").classList.remove("hidden");
        document.getElementById("bulk-progress-summary").style.display = "flex";
        if (isScanAnomalies) {
            document.getElementById("bulk-progress-success-count").innerHTML =
                `<i class="fa-solid fa-check"></i> ${st.success || 0} escaneados`;
        } else {
            document.getElementById("bulk-progress-success-count").innerHTML =
                `<i class="fa-solid fa-check"></i> ${st.success || 0} OK`;
        }
        document.getElementById("bulk-progress-skipped-count").innerHTML =
            `<i class="fa-solid fa-forward"></i> ${st.skipped || 0} omitidos`;
        document.getElementById("bulk-progress-failed-count").innerHTML =
            `<i class="fa-solid fa-xmark"></i> ${st.failed || 0} fallos`;

        const anomCountEl = document.getElementById("bulk-progress-anomalies-count");
        if (isScanAnomalies && totalAnoms > 0) {
            anomCountEl.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> ${totalAnoms} anomalías`;
            anomCountEl.classList.remove("hidden");
        } else if (anomCountEl) {
            anomCountEl.classList.add("hidden");
        }

        const dlBtn = document.getElementById("btn-bulk-download-report");
        if (dlBtn) {
            if (isScanAnomalies && st.reportCsv) {
                dlBtn.classList.remove("hidden");
                dlBtn.onclick = () => { window.open(`/api/exports/${encodeURIComponent(st.reportCsv)}`, "_blank"); };
            } else {
                dlBtn.classList.add("hidden");
            }
        }
        const xlsxBtn = document.getElementById("btn-bulk-download-xlsx");
        if (xlsxBtn) {
            if (isScanAnomalies && st.reportCsv && totalAnoms > 0) {
                xlsxBtn.classList.remove("hidden");
                xlsxBtn.onclick = () => { window.open(`/api/workflows/anomaly-report-xlsx/${encodeURIComponent(st.reportCsv)}`, "_blank"); };
            } else {
                xlsxBtn.classList.add("hidden");
            }
        }
        // Ocultar botón Cancelar al finalizar.
        const cancelBtn = document.getElementById("btn-bulk-cancel");
        if (cancelBtn) cancelBtn.classList.add("hidden");

        // Botón "Reintentar pendientes" solo si hubo sesión expirada y hay items no-exitosos.
        const retryBtn = document.getElementById("btn-bulk-retry-pending");
        const retryCountEl = document.getElementById("bulk-retry-count");
        if (retryBtn && retryCountEl) {
            const pending = _bulkGetPendingItems();
            if (st.sessionExpired && pending.length > 0) {
                retryCountEl.textContent = pending.length;
                retryBtn.classList.remove("hidden");
            } else {
                retryBtn.classList.add("hidden");
            }
        }

        // Render del panel de anomalías cuando aplique.
        bulkRenderAnomaliesPanel();

        // Toast informativo al finalizar el escaneo.
        if (isScanAnomalies && !st._toastShown) {
            st._toastShown = true;
            if (totalAnoms > 0) {
                showToast(`${totalAnoms} anomalía(s) detectada(s) en ${workflowsWithAnoms} workflow(s). Revisa el panel.`, "warning");
            } else {
                showToast("Escaneo completo — ningún workflow tiene anomalías.", "success");
            }
        }
    } else if (st.currentName) {
        const acc = st.currentAccount ? ` en ${st.currentAccount}` : "";
        const runningAnoms = totalAnoms > 0
            ? ` <span style="color:#F59E0B; font-weight:bold;">(${totalAnoms} anomalías detectadas hasta ahora)</span>`
            : "";
        document.getElementById("bulk-progress-current").innerHTML =
            `Trabajando en: <strong>${st.currentName}</strong>${acc}${runningAnoms}`;
    }
}

function bulkRenderAnomaliesPanel() {
    const panel = document.getElementById("bulk-anomalies-panel");
    if (!panel || !bulkProgressState) return;
    const st = bulkProgressState;
    const isScanAnomalies = st.target === "scan-anomalies";
    const findings = st.findings || [];
    if (!isScanAnomalies || findings.length === 0) {
        panel.classList.add("hidden");
        return;
    }
    panel.classList.remove("hidden");
    const workflowsWithAnoms = Object.keys(st.anomaliesPerWorkflow || {}).filter(k => st.anomaliesPerWorkflow[k] > 0).length;
    document.getElementById("bulk-anomalies-title").textContent =
        `${findings.length} anomalía(s) detectada(s) en ${workflowsWithAnoms} workflow(s)`;

    const list = document.getElementById("bulk-anomalies-list");
    // Agrupar por workflow.
    const byWf = {};
    for (const f of findings) {
        const key = `${f.locationId}::${f.workflowId}`;
        if (!byWf[key]) byWf[key] = { locationId: f.locationId, workflowId: f.workflowId, workflowName: f.workflowName, accountName: f.accountName, items: [] };
        byWf[key].items.push(f);
    }
    const groupsHtml = Object.values(byWf).map(g => {
        // Agrupar findings idénticos por (node, type, desc). Si 3 nodos "Crear oportunidad"
        // tienen el mismo error, los mostramos como UNA entrada con "3×" y los StepIds en lista.
        const dedup = new Map();
        for (const f of g.items) {
            const key = `${f.type}|${f.node}|${f.desc}`;
            if (!dedup.has(key)) dedup.set(key, {type: f.type, node: f.node, desc: f.desc, ids: []});
            if (f.id) dedup.get(key).ids.push(f.id);
        }
        const itemsHtml = Array.from(dedup.values()).map(f => {
            const typeLabel = ({
                "ghl_native_error": "Error nativo de GHL",
                "alert_icon": "Ícono de alerta",
                "unresolved_field_id": "ID de campo sin resolver",
                "n_button_alert": "Aviso en cabecera",
            })[f.type] || f.type;
            const typeColor = f.type === "ghl_native_error" ? "#F87171" : "#F59E0B";
            const count = f.ids.length || 1;
            const countPrefix = count > 1 ? `<span style="color:${typeColor}; font-weight:bold; margin-right:6px;">${count}×</span>` : "";
            const idsHtml = f.ids.length > 0
                ? `<div style="margin-top: 3px; color: var(--text-muted); font-family: monospace; font-size: 11px; white-space: pre-line;">${f.ids.join("\n")}</div>`
                : "";
            return `
                <div style="padding:6px 8px; background: rgba(255,255,255,0.03); border-left: 3px solid ${typeColor}; border-radius: 4px; font-size: 12px;">
                    <div style="display:flex; gap:8px; align-items:center;">
                        <span class="badge" style="background:${typeColor}22; color:${typeColor}; font-size:10px; padding:2px 6px; border-radius:4px;">${typeLabel}</span>
                        ${countPrefix}<strong>${f.node || "(sin nombre)"}</strong>
                    </div>
                    ${f.desc ? `<div style="margin-top: 3px; color: var(--text-secondary);">${f.desc}</div>` : ""}
                    ${idsHtml}
                </div>
            `;
        }).join("");
        const wfUrl = `https://crm.bucefalocrm.io/location/${g.locationId}/workflow/${g.workflowId}`;
        return `
            <div style="padding:10px; background: rgba(0,0,0,0.25); border-radius: 6px; display:flex; flex-direction:column; gap:6px;">
                <div style="display:flex; align-items:center; gap:8px; flex-wrap: wrap;">
                    <i class="fa-solid fa-diagram-project" style="color:#A78BFA;"></i>
                    <strong>${g.workflowName}</strong>
                    <span class="text-muted" style="font-size: 12px;">${g.accountName || g.locationId}</span>
                    <span class="badge" style="background: rgba(245, 158, 11, 0.18); color:#F59E0B; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:bold;">${g.items.length} anomalía(s)</span>
                    <a href="${wfUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="margin-left:auto; padding:4px 10px; font-size:12px; text-decoration:none; color:#FFFFFF; background: rgba(96, 165, 250, 0.18); border-color: rgba(96, 165, 250, 0.45);">
                        <i class="fa-solid fa-up-right-from-square" style="color:#60A5FA;"></i> <span style="color:#FFFFFF;">Abrir en Bucéfalo</span>
                    </a>
                </div>
                <div style="display:flex; flex-direction:column; gap:4px;">
                    ${itemsHtml}
                </div>
            </div>
        `;
    }).join("");
    list.innerHTML = groupsHtml;
}

async function cancelBulkScan() {
    if (!bulkProgressState || !bulkProgressState.taskId) {
        showToast("No hay escaneo activo para cancelar.", "warning");
        return;
    }
    const ok = await appConfirm({
        title: "Cancelar escaneo",
        message: "El subproceso de Playwright se detendrá ahora. Los workflows ya escaneados aparecerán en el reporte parcial; el resto quedarán como pendientes.",
        confirmText: "Sí, cancelar",
        cancelText: "Continuar escaneo",
        severity: "danger",
    });
    if (!ok) return;
    try {
        const res = await fetch(`/api/scripts/stop/${bulkProgressState.taskId}`, {method: "POST"});
        const data = await res.json();
        if (!res.ok) throw new Error(data.detail || "No se pudo cancelar");
        bulkProgressState.cancelled = true;
        showToast("Escaneo cancelado. Cerrando subproceso...", "info");
    } catch (err) {
        showToast(`Error: ${err.message}`, "error");
    }
}

function bulkToggleAnomaliesList() {
    const list = document.getElementById("bulk-anomalies-list");
    const label = document.getElementById("bulk-anomalies-toggle-label");
    const icon = document.querySelector("#btn-bulk-anomalies-toggle i");
    if (!list) return;
    const hidden = list.classList.toggle("hidden");
    if (label) label.textContent = hidden ? "Ver detalle" : "Ocultar detalle";
    if (icon) icon.className = hidden ? "fa-solid fa-chevron-down" : "fa-solid fa-chevron-up";
}

function bulkParseLine(line) {
    if (!bulkProgressState) return;
    // "[BULK-DRAFT] Iniciando procesamiento de N workflows..."
    let m = line.match(/Iniciando procesamiento de (\d+) workflows/);
    if (m) {
        bulkProgressState.total = parseInt(m[1], 10);
        bulkRenderProgress();
        return;
    }
    // "[BULK X/N] === 'name' (id) en location_id ==="
    m = line.match(/\[BULK (\d+)\/(\d+)\] === '([^']+)' \(([^)]+)\) en (\S+) ===/);
    if (m) {
        bulkProgressState.currentIdx = parseInt(m[1], 10);
        bulkProgressState.total = parseInt(m[2], 10);
        bulkProgressState.currentName = m[3];
        // Resolver el nombre de la sucursal desde workflowsData si está disponible.
        const locId = m[5];
        const accMatch = workflowsData.find(w => w.location_id === locId);
        bulkProgressState.currentAccount = accMatch ? accMatch.account_name : locId;
        bulkRenderProgress();
        return;
    }
    // NOTA: las líneas "[BULK X/N] SKIP — …" se ignoran a propósito. La fuente de verdad
    // para contar items es exclusivamente "RESULT:" emitido al final de cada item. Cada SKIP
    // del script va siempre acompañado de un RESULT: skipped a continuación.
    // Marcador final por item: "[BULK X/N] RESULT: success|failed|skipped"
    m = line.match(/\[BULK (\d+)\/(\d+)\] RESULT: (success|failed|skipped)/);
    if (m) {
        const idx0 = parseInt(m[1], 10) - 1;
        const status = m[3];
        if (status === "success") {
            bulkProgressState.success = (bulkProgressState.success || 0) + 1;
        } else if (status === "skipped") {
            bulkProgressState.skipped = (bulkProgressState.skipped || 0) + 1;
        } else {
            bulkProgressState.failed = (bulkProgressState.failed || 0) + 1;
        }
        bulkProgressState.done = (bulkProgressState.done || 0) + 1;
        // Track del status por índice para identificar pendientes al final.
        if (bulkProgressState.itemStatus && idx0 >= 0 && idx0 < bulkProgressState.itemStatus.length) {
            bulkProgressState.itemStatus[idx0] = status;
        }
        bulkRenderProgress();
        return;
    }
    // Aborto por sesión expirada: marcar el estado para mostrar banner al final.
    if (/\[BULK \d+\/\d+\] SESIÓN EXPIRADA/.test(line) || /\[ABORTO\] .* abortado por sesión expirada/.test(line)) {
        bulkProgressState.sessionExpired = true;
        return;
    }
    // Hallazgo individual de anomalía: "[BULK X/N] FINDING: type=...|node=...|id=...|desc=..."
    m = line.match(/\[BULK (\d+)\/(\d+)\] FINDING:\s*(.+)$/);
    if (m) {
        const idx0 = parseInt(m[1], 10) - 1;
        const item = (bulkProgressState.items || [])[idx0] || {};
        const parts = {};
        for (const kv of m[3].split("|")) {
            const eq = kv.indexOf("=");
            if (eq > 0) parts[kv.slice(0, eq).trim()] = kv.slice(eq + 1).trim();
        }
        bulkProgressState.findings = bulkProgressState.findings || [];
        bulkProgressState.findings.push({
            workflowId: item.workflow_id || "",
            locationId: item.location_id || "",
            workflowName: item.name || "",
            accountName: item.account_name || "",
            type: parts.type || "",
            node: parts.node || "",
            id: parts.id || "",
            desc: parts.desc || "",
        });
        // Re-render durante el progreso (con throttle implícito porque solo se llama por línea).
        bulkRenderAnomaliesPanel();
        bulkRenderProgress();
        return;
    }
    // Conteo de anomalías por workflow: "[BULK X/N] ANOMALIES: N"
    m = line.match(/\[BULK (\d+)\/(\d+)\] ANOMALIES:\s*(\d+)/);
    if (m) {
        const idx0 = parseInt(m[1], 10) - 1;
        const count = parseInt(m[3], 10);
        bulkProgressState.anomaliesPerWorkflow = bulkProgressState.anomaliesPerWorkflow || {};
        const item = (bulkProgressState.items || [])[idx0];
        if (item) {
            const key = `${item.location_id}::${item.workflow_id}`;
            bulkProgressState.anomaliesPerWorkflow[key] = count;
            // Marcar el workflow en el cache global para que renderWorkflowsTable pinte un badge.
            window.__workflowAnomaliesCache = window.__workflowAnomaliesCache || {};
            window.__workflowAnomaliesCache[key] = count;
        }
        bulkRenderProgress();
        return;
    }
    // Path del CSV del reporte: "[INFO] REPORT_CSV: <filename>"
    m = line.match(/\[INFO\]\s+REPORT_CSV:\s*(.+)$/);
    if (m) {
        bulkProgressState.reportCsv = m[1].trim();
        bulkRenderProgress();
        return;
    }
    m = line.match(/\[INFO\]\s+REPORT_JSON:\s*(.+)$/);
    if (m) {
        bulkProgressState.reportJson = m[1].trim();
        return;
    }
    // Resumen final.
    if (/=== RESUMEN BULK-/.test(line)) {
        bulkProgressState.finished = true;
        bulkRenderProgress();
        // Refrescar la tabla 3s después de ver el resumen para dar margen al sync local.
        // EXCEPCIÓN: scan-anomalies es read-only, recargar la tabla borra los badges recién pintados.
        if (bulkProgressState.target !== "scan-anomalies") {
            setTimeout(() => loadWorkflowsTable(), 3000);
        } else {
            // Refrescar solo el render para que los badges aparezcan en las filas.
            setTimeout(() => { if (typeof renderWorkflowsTable === "function") renderWorkflowsTable(); }, 500);
        }
        return;
    }
}

function bulkStartSseStream(taskId) {
    if (bulkProgressEventSource) {
        bulkProgressEventSource.close();
    }
    bulkProgressEventSource = new EventSource(`/api/scripts/stream/${taskId}`);
    bulkProgressEventSource.onmessage = (ev) => {
        const line = ev.data || "";
        if (line === "[EOF]") {
            // El stream terminó. Si el resumen no marcó finished, marcarlo ahora.
            if (bulkProgressState && !bulkProgressState.finished) {
                bulkProgressState.finished = true;
                bulkRenderProgress();
                setTimeout(() => loadWorkflowsTable(), 2000);
            }
            bulkProgressEventSource.close();
            bulkProgressEventSource = null;
            return;
        }
        try {
            bulkParseLine(line);
        } catch (e) {
            console.warn("bulkParseLine:", e);
        }
    };
    bulkProgressEventSource.onerror = () => {
        // SSE puede reconectar solo; lo cerramos si la tarea terminó.
        if (bulkProgressState && bulkProgressState.finished) {
            bulkProgressEventSource.close();
            bulkProgressEventSource = null;
        }
    };
}

async function triggerBulkToggle(target) {
    // target: "draft" | "publish" | "delete"
    if (selectedWorkflows.size === 0) {
        showToast("No hay workflows seleccionados.", "warning");
        return;
    }
    const items = Array.from(selectedWorkflows.values());

    let targetLabel, msg;
    if (target === "delete") {
        targetLabel = "Eliminar";
        const names = items.slice(0, 5).map(i => `• ${i.name} (${i.account_name || i.location_id})`).join("\n");
        const more = items.length > 5 ? `\n…y ${items.length - 5} más.` : "";
        const summaryMsg = `Vas a eliminar ${items.length} workflow(s) de Bucéfalo:\n\n${names}${more}\n\nEsta acción NO se puede deshacer desde aquí (GHL los mueve a "Eliminado" 30 días y luego los borra permanentemente).`;
        const okSummary = await appConfirm({
            title: "Eliminación permanente",
            message: summaryMsg,
            confirmText: "Continuar",
            severity: "danger",
        });
        if (!okSummary) return;
        const okTyped = await appPrompt({
            title: "Confirma la eliminación",
            message: `Para confirmar definitivamente, escribe ELIMINAR (en mayúsculas):`,
            expectedAnswer: "ELIMINAR",
            placeholder: "ELIMINAR",
            severity: "danger",
        });
        if (!okTyped) {
            showToast("Confirmación cancelada — no se eliminó nada.", "info");
            return;
        }
        msg = null;  // Ya confirmamos.
    } else {
        const isPublish = target === "publish";
        targetLabel = isPublish ? "Publicado" : "Borrador";
        const alreadyAtTarget = items.filter(i => {
            const isActive = i.current_status === "active" || i.current_status === "published";
            return isActive === isPublish;
        }).length;
        const willMutate = items.length - alreadyAtTarget;
        msg = `Vas a poner ${willMutate} workflow(s) en ${targetLabel}.`;
        if (alreadyAtTarget > 0) msg += ` (${alreadyAtTarget} ya están en ${targetLabel} y se omitirán.)`;
        msg += "\n\nLa operación se ejecuta secuencialmente uno por uno.";
        const ok = await appConfirm({
            title: `Poner workflows en ${targetLabel}`,
            message: msg,
            confirmText: "Continuar",
            severity: "info",
        });
        if (!ok) return;
    }

    // Delegar al ejecutor compartido (también lo usa el botón "Reintentar pendientes").
    await _executeBulkOperation(target, items, targetLabel);
}

async function triggerBulkScanAnomalies() {
    if (selectedWorkflows.size === 0) {
        showToast("No hay workflows seleccionados.", "warning");
        return;
    }
    const items = Array.from(selectedWorkflows.values());
    const targetLabel = "Escanear anomalías";
    const eta = Math.ceil((items.length * 40) / 60);
    const ok = await appConfirm({
        title: targetLabel,
        message: `Vas a escanear ${items.length} workflow(s) en busca de nodos con anomalías (íconos de alerta, IDs de campos sin resolver, avisos globales). Es solo lectura — no modifica nada en Bucéfalo.\n\nTiempo estimado: ~${eta} min (≈40s por workflow).\n\nEl reporte queda en generated/reports/workflow_anomalies/.`,
        confirmText: "Iniciar escaneo",
        severity: "info",
    });
    if (!ok) return;
    await _executeBulkOperation("scan-anomalies", items, targetLabel);
}

async function scanWorkflowAnomalies(workflowId, locationId, name, accountName) {
    const item = {
        workflow_id: workflowId,
        location_id: locationId,
        name: name || "",
        account_name: accountName || locationId,
        current_status: "",
    };
    const ok = await appConfirm({
        title: "Escanear anomalías",
        message: `Vas a abrir "${name}" en el editor visual y escanear sus nodos buscando anomalías (íconos de alerta, IDs de campos sin resolver, avisos globales). Solo lectura — no modifica nada en Bucéfalo.\n\nTarda ~30-40s.`,
        confirmText: "Iniciar escaneo",
        severity: "info",
    });
    if (!ok) return;
    await _executeBulkOperation("scan-anomalies", [item], "Escanear anomalías");
}

/**
 * Ejecuta el POST al backend de bulk-* y arma el progreso visual + tracking de items.
 * Se usa tanto desde `triggerBulkToggle` (flujo normal) como desde el botón de
 * "Reintentar pendientes" (que ya no pasa por confirmación).
 */
async function _executeBulkOperation(target, items, targetLabel) {
    try {
        const res = await mutateFetch(`/api/workflows/bulk-${target}`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ items }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.detail || `Error en bulk-${target}`);

        // Estado de progreso. `items` guardado completo para luego poder identificar pendientes.
        // `itemStatus` indexado por idx (1-based, matchea con [BULK X/N]).
        bulkProgressState = {
            total: items.length,
            currentIdx: 0,
            currentName: null,
            currentAccount: null,
            done: 0,
            success: 0,
            skipped: 0,
            failed: 0,
            finished: false,
            target,
            targetLabel,
            items: items.slice(),
            itemStatus: new Array(items.length).fill(null), // index 0-based; idx 1-based del log → idx-1
            sessionExpired: false,
        };
        bulkProgressState.taskId = data.task_id || null;
        bulkEnterProgressMode(targetLabel);
        clearWorkflowSelection();
        document.getElementById("workflows-bulk-bar").classList.remove("hidden");

        if (data.task_id) {
            bulkStartSseStream(data.task_id);
        }
    } catch (err) {
        showToast(`Error: ${err.message}`, "error");
    }
}

function _bulkGetPendingItems() {
    // Items que NO terminaron con éxito tras un bulk interrumpido por sesión expirada.
    // Es decir: status != 'success'. Incluye 'failed', 'skipped' por sesión, y null (no procesados).
    if (!bulkProgressState || !bulkProgressState.items) return [];
    const pending = [];
    bulkProgressState.items.forEach((item, i) => {
        const st = bulkProgressState.itemStatus[i];
        if (st !== "success") pending.push(item);
    });
    return pending;
}

async function retryBulkPending() {
    if (!bulkProgressState) return;
    const pending = _bulkGetPendingItems();
    if (pending.length === 0) {
        showToast("No hay pendientes para reintentar.", "info");
        return;
    }
    const target = bulkProgressState.target;
    const targetLabel = bulkProgressState.targetLabel;

    // Confirmación corta (no doble paso aquí: el usuario ya confirmó la operación inicial).
    const ok = await appConfirm({
        title: `Reintentar ${pending.length} pendientes`,
        message: `Vas a reintentar ${pending.length} workflow(s) que no se procesaron en el lote anterior (acción: ${targetLabel}).`,
        confirmText: "Reintentar",
        severity: target === "delete" ? "danger" : "info",
    });
    if (!ok) return;

    // Cerrar el progreso viejo y arrancar uno nuevo con los pendientes.
    bulkExitProgressMode();
    await _executeBulkOperation(target, pending, targetLabel);
}

async function triggerWorkflowsSync() {
    const btn = document.getElementById("btn-sync-workflows");
    if (!btn) return;
    
    const oldHtml = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sincronizando...';
    
    showToast("Iniciando sincronización de workflows...", "info");
    
    try {
        let url = "/api/workflows/sync";
        if (activeBranchId !== "global") {
            url += `?location_id=${activeBranchId}`;
        }
        
        const response = await fetch(url, { method: "POST" });
        if (!response.ok) throw new Error("Error al disparar la sincronización");
        
        const data = await response.json();
        showToast(data.message, "success");
        
        setTimeout(async () => {
            await loadWorkflowsTable();
            showToast("Lista de workflows actualizada.", "success");
        }, 3000);
        
    } catch (err) {
        console.error(err);
        showToast(`Error al sincronizar workflows: ${err.message}`, "error");
    } finally {
        btn.disabled = false;
        btn.innerHTML = oldHtml;
    }
}

async function updateBucefaloSessionIndicator() {
    const el = document.getElementById("workflow-session-status");
    if (!el) return;
    try {
        const res = await fetch("/api/browser-session/status");
        if (!res.ok) return;
        const data = await res.json();
        if (!data.exists) {
            el.innerHTML = '<i class="fa-solid fa-circle-exclamation"></i> Sesión Bucéfalo: no existe';
            el.style.color = "#F87171";
            return;
        }
        const ageTxt = data.age_hours == null ? "?" : `${data.age_hours} h`;
        if (data.stale) {
            el.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> Sesión Bucéfalo: ${ageTxt} (probablemente expirada)`;
            el.style.color = "#FBBF24";
        } else {
            el.innerHTML = `<i class="fa-solid fa-circle-check"></i> Sesión Bucéfalo: ${ageTxt}`;
            el.style.color = "#34D399";
        }
    } catch (_) {
        // silencioso — el endpoint puede no existir en versiones viejas
    }
}

// Traduce líneas técnicas del log del session generator a estados humanos.
function _interpretSessionLogLine(line) {
    if (/Iniciando flujo de auto-login/.test(line))               return { title: "Conectando a Bucéfalo…",            detail: "Modo auto-login con credenciales del .env." };
    if (/Llenando email \+ contraseña/.test(line))                return { title: "Llenando credenciales…",             detail: "Completando email y contraseña." };
    if (/Click en 'Iniciar sesión'/.test(line))                   return { title: "Enviando credenciales…",             detail: "" };
    if (/Esperando selector de método 2FA/.test(line))            return { title: "Esperando selector de 2FA…",          detail: "Bucéfalo va a pedir un código." };
    if (/Método 2FA seleccionado: Email/.test(line))              return { title: "Solicitando código por correo…",      detail: "El correo tarda 20–30 s en llegar." };
    if (/Esperando código OTP por IMAP/.test(line))               return { title: "Esperando el código en el correo…",   detail: "Leyendo INBOX por IMAP." };
    const codeMatch = line.match(/Código obtenido:\s*(\d{6})/);
    if (codeMatch)                                                return { title: "Código recibido",                     detail: `Pegando ${codeMatch[1]} en el formulario…` };
    if (/Código tipeado en/.test(line))                           return { title: "Verificando código…",                 detail: "" };
    if (/Login completado/.test(line))                            return { title: "Login completado",                    detail: "Guardando sesión…" };
    if (/Lanzando navegador Chromium/.test(line))                 return { title: "Abriendo navegador…",                 detail: "Completa login + MFA en la ventana." };
    if (/Esperando hasta \d+ s a que completes login/.test(line)) return { title: "Esperando tu login…",                 detail: "Completa login + MFA en la ventana del navegador." };
    if (/Login detectado/.test(line))                             return { title: "Login detectado",                     detail: "Estabilizando cookies…" };
    return null;
}

async function refreshBucefaloSession() {
    const ok = await appConfirm({
        title: "Renovar sesión Bucéfalo",
        message: "Si configuraste .env con credenciales, el login es automático (sin ventana visible). Si no, se abre una ventana del navegador para que completes login + MFA.",
        confirmText: "Renovar",
        severity: "info",
    });
    if (!ok) return;

    const btn = document.getElementById("btn-refresh-session");
    const oldHtml = btn ? btn.innerHTML : null;
    if (btn) {
        btn.disabled = true;
        btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Renovando…';
    }

    const progress = appProgressModal({
        title: "Renovando sesión Bucéfalo",
        initialDetail: "Iniciando…",
        severity: "info",
    });
    progress.onClose(() => {
        if (btn) { btn.disabled = false; btn.innerHTML = oldHtml; }
        updateBucefaloSessionIndicator();
    });

    let lastTitle = "Iniciando…";
    let sawSuccess = false;
    let lastErrorDetail = null;
    let sse = null;

    try {
        const res = await fetch("/api/browser-session/refresh", { method: "POST" });
        const data = await res.json();
        if (!res.ok) throw new Error(data.detail || "Error al lanzar el generador de sesión");

        const taskId = data.task_id;
        if (!taskId) {
            // Compatibilidad con endpoint viejo sin task_id.
            progress.setStatus("Generador iniciado", "Esperando que termine en background…");
            setTimeout(() => progress.setSuccess("Listo", "Verifica el indicador de sesión."), 5000);
            return;
        }

        progress.setStatus("Conectando al subprocess…", "Esperando primeras líneas de log.");

        sse = new EventSource(`/api/scripts/stream/${taskId}`);
        sse.onmessage = async (ev) => {
            const line = ev.data || "";
            // EOF marca el fin del stream del subprocess.
            if (line === "[EOF]") {
                if (sse) { sse.close(); sse = null; }
                if (sawSuccess) {
                    progress.setSuccess("Sesión renovada", "Bucéfalo está listo para procesar acciones.");
                    // Si hay un bulk previo en estado sessionExpired con pendientes,
                    // resaltar visualmente el botón "Reintentar pendientes" para que el usuario lo note.
                    setTimeout(() => {
                        const retryBtn = document.getElementById("btn-bulk-retry-pending");
                        if (retryBtn && !retryBtn.classList.contains("hidden")) {
                            retryBtn.style.transition = "transform 0.3s ease, box-shadow 0.3s ease";
                            retryBtn.style.transform = "scale(1.05)";
                            retryBtn.style.boxShadow = "0 0 0 4px rgba(124, 58, 237, 0.35)";
                            setTimeout(() => {
                                retryBtn.style.transform = "";
                                retryBtn.style.boxShadow = "";
                            }, 1400);
                        }
                    }, 600);
                } else if (lastErrorDetail) {
                    progress.setError("Falló la renovación", lastErrorDetail);
                } else {
                    // No vimos éxito ni error claro: verificar el estado real de la sesión.
                    try {
                        const s = await fetch("/api/browser-session/status").then(r => r.json());
                        if (s.exists && (s.age_hours == null || s.age_hours < 0.5)) {
                            progress.setSuccess("Sesión renovada", `Archivo actualizado (${s.age_hours ?? 0} h).`);
                        } else {
                            progress.setError("No se pudo confirmar la renovación", "Revisa la terminal del server para más detalle.");
                        }
                    } catch (_) {
                        progress.setError("Sin confirmación", "El subprocess terminó pero no pude verificar el estado.");
                    }
                }
                return;
            }

            // Interpretación humanizada del estado actual.
            const interp = _interpretSessionLogLine(line);
            if (interp) lastTitle = interp.title;
            const shortLine = line.length > 90 ? line.slice(0, 87) + "…" : line;
            progress.setStatus(lastTitle, interp ? interp.detail : undefined, shortLine);

            // Detección de éxito / error.
            if (/\[ÉXITO\] Sesión guardada en/.test(line) || /\[ÉXITO\] Perfil guardado en/.test(line)) {
                sawSuccess = true;
            }
            if (/Auto-login falló/.test(line) ||
                /\[ERROR\]/.test(line) ||
                /OtpReaderError/.test(line) ||
                /AUTHENTICATIONFAILED/i.test(line) ||
                /Tiempo agotado esperando el login/.test(line)) {
                lastErrorDetail = line.replace(/^\[(ERROR|AUTO|OTP)\]\s*/, '').slice(0, 200);
            }
        };
        sse.onerror = () => {
            // Reconnects automáticos del EventSource; no actuamos salvo en EOF.
        };
    } catch (err) {
        progress.setError("Error al iniciar", err.message || String(err));
    }
}

async function exportWorkflowsExcel() {
    const btn = document.getElementById("btn-export-workflows");
    if (!btn) return;

    const oldHtml = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Exportando...';

    try {
        const params = new URLSearchParams();
        const branchFilter = document.getElementById("workflow-branch-select")?.value || "all";
        const statusFilter = document.getElementById("workflow-status-select")?.value || "all";
        const searchQuery = (document.getElementById("workflow-table-search")?.value || "").trim();

        if (activeBranchId !== "global") {
            params.set("location_id", activeBranchId);
        } else if (branchFilter !== "all") {
            params.set("location_id", branchFilter);
        }

        if (statusFilter !== "all") {
            params.set("status", statusFilter);
        }

        if (searchQuery) {
            params.set("q", searchQuery);
        }

        const queryString = params.toString();
        const response = await fetch(`/api/workflows/export${queryString ? `?${queryString}` : ""}`);
        if (!response.ok) throw new Error("Error al exportar workflows");

        const blob = await response.blob();
        const contentDisposition = response.headers.get("Content-Disposition") || "";
        const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/);
        const filename = filenameMatch ? filenameMatch[1] : "workflows_ghl.xlsx";

        const downloadUrl = URL.createObjectURL(blob);
        const link = document.createElement("a");
        link.href = downloadUrl;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        URL.revokeObjectURL(downloadUrl);

        showToast("Excel de workflows descargado.", "success");
    } catch (err) {
        console.error(err);
        showToast(`Error al exportar workflows: ${err.message}`, "error");
    } finally {
        btn.disabled = false;
        btn.innerHTML = oldHtml;
    }
}

// --- FUNCIONES GESTIÓN DE WORKFLOWS CON PLAYWRIGHT ---

async function toggleWorkflowState(workflowId, locationId, currentStatus) {
    const isActive = currentStatus === "active" || currentStatus === "published";
    const targetLabel = isActive ? "Borrador" : "Publicado";
    const ok = await appConfirm({
        title: "Cambiar estado del workflow",
        message: `El workflow está actualmente en ${isActive ? "Publicado" : "Borrador"}.\n\nSe va a cambiar a ${targetLabel} en Bucéfalo.`,
        confirmText: "Cambiar",
        severity: "warning",
    });
    if (!ok) return;
    showToast("Disparando cambio de estado...", "info");
    
    mutateFetch(`/api/workflows/${workflowId}/toggle-status?location_id=${locationId}&current_status=${currentStatus}`, { method: "POST" })
    .then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); })
    .then(data => {
        showToast("Proceso iniciado en segundo plano.", "success");
        if (data.task_id) monitorPlaywrightTask(data.task_id);
    })
    .catch(err => showToast(err.message, "error"));
}

function openRenameWorkflowModal(workflowId, locationId, currentName) {
    document.getElementById("rename-wf-id").value = workflowId;
    document.getElementById("rename-wf-location").value = locationId;
    document.getElementById("rename-wf-input").value = currentName;
    document.getElementById("modal-rename-workflow").classList.remove("hidden");
}

function closeRenameWorkflowModal() {
    document.getElementById("modal-rename-workflow").classList.add("hidden");
}

function submitRenameWorkflow() {
    const wfId = document.getElementById("rename-wf-id").value;
    const locId = document.getElementById("rename-wf-location").value;
    const newName = document.getElementById("rename-wf-input").value.trim();
    if (!newName) { showToast("El nombre es requerido.", "error"); return; }
    
    closeRenameWorkflowModal();
    showToast("Disparando renombrado en GHL...", "info");
    
    mutateFetch(`/api/workflows/${wfId}/rename?location_id=${locId}&new_name=${encodeURIComponent(newName)}`, { method: "POST" })
    .then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); })
    .then(data => {
        showToast("Proceso iniciado.", "success");
        if (data.task_id) monitorPlaywrightTask(data.task_id);
    })
    .catch(err => showToast(err.message, "error"));
}

function openLocationInfoModal() {
    if (activeBranchId === "global") {
        showToast("Estás en la vista global consolidada. Elige una sucursal específica para ver sus pipelines.", "info");
        return;
    }
    
    // Resetear/limpiar modal anterior
    document.getElementById("location-info-loader").classList.remove("hidden");
    document.getElementById("location-info-content").classList.add("hidden");
    document.getElementById("location-pipelines-container").innerHTML = "";
    
    document.getElementById("modal-location-info").classList.remove("hidden");
    
    // Obtener información de la ubicación/cuenta de GHL + local
    fetch(`/api/location/${activeBranchId}`)
    .then(res => {
        if (!res.ok) throw new Error("Error al consultar detalles de la cuenta");
        return res.json();
    })
    .then(data => {
        const local = data.local_data || {};
        const ghl = data.ghl_data || {};
        
        document.getElementById("loc-info-name").innerText = ghl.name || local.nombre || "-";
        document.getElementById("loc-info-id").innerText = local.location_id || activeBranchId;
        document.getElementById("loc-info-owner").innerText = ghl.business?.name || local.company_owner || "Monte Providencia";
        document.getElementById("loc-info-phone").innerText = ghl.phone || "-";
        document.getElementById("loc-info-email").innerText = ghl.email || "-";
        document.getElementById("loc-info-timezone").innerText = ghl.timezone || "-";
        
        const fullAddress = [ghl.address, ghl.city, ghl.state, ghl.country, ghl.postalCode]
            .filter(Boolean)
            .join(", ") || "No disponible";
        document.getElementById("loc-info-address").innerText = fullAddress;
        
        // Ahora cargar pipelines de la misma cuenta/sucursal
        return fetch(`/api/pipelines/${activeBranchId}`);
    })
    .then(res => {
        if (!res.ok) throw new Error("Error al consultar los pipelines de la cuenta");
        return res.json();
    })
    .then(data => {
        const pipelines = data.pipelines || [];
        const pipelinesContainer = document.getElementById("location-pipelines-container");
        pipelinesContainer.innerHTML = "";
        
        if (pipelines.length === 0) {
            pipelinesContainer.innerHTML = `<p class="text-secondary" style="font-size: 13px; margin: 0;">No se encontraron pipelines activos para esta cuenta en SQLite.</p>`;
        } else {
            pipelines.forEach(pipe => {
                const pipeDiv = document.createElement("div");
                pipeDiv.style.background = "rgba(255, 255, 255, 0.02)";
                pipeDiv.style.border = "1px solid var(--border-glass)";
                pipeDiv.style.borderRadius = "var(--radius-sm)";
                pipeDiv.style.padding = "16px";
                pipeDiv.style.marginBottom = "12px";
                
                let stagesList = "";
                if (pipe.stages && pipe.stages.length > 0) {
                    stagesList = pipe.stages.map((stg, index) => `
                        <div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; background: rgba(255, 255, 255, 0.01); border-radius: var(--radius-sm); border-left: 3px solid var(--color-primary); margin-bottom: 4px;">
                            <div style="display: flex; align-items: center; gap: 8px;">
                                <span style="color: var(--text-muted); font-size: 11px; font-family: var(--font-mono); width: 16px;">#${index + 1}</span>
                                <strong style="font-size: 13px; color: var(--text-primary);">${stg.name}</strong>
                            </div>
                            <code style="font-size: 11px; color: var(--text-muted);">${stg.id}</code>
                        </div>
                    `).join("");
                } else {
                    stagesList = `<p class="text-secondary" style="font-size: 12px; margin: 0; padding-left: 12px;">Sin fases / etapas registradas.</p>`;
                }
                
                pipeDiv.innerHTML = `
                    <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
                        <h5 style="margin: 0; color: var(--text-primary); font-size: 15px; display: flex; align-items: center; gap: 8px;">
                            <i class="fa-solid fa-filter" style="color: var(--color-primary-glow);"></i> ${pipe.name}
                        </h5>
                        <code style="font-size: 11px; color: var(--text-muted);">${pipe.id}</code>
                    </div>
                    <div style="display: flex; flex-direction: column; gap: 6px; padding-left: 8px;">
                        ${stagesList}
                    </div>
                `;
                pipelinesContainer.appendChild(pipeDiv);
            });
        }
        
        // Quitar loader, mostrar contenido
        document.getElementById("location-info-loader").classList.add("hidden");
        document.getElementById("location-info-content").classList.remove("hidden");
    })
    .catch(err => {
        console.error(err);
        showToast(err.message, "error");
        closeLocationInfoModal();
    });
}

function closeLocationInfoModal() {
    document.getElementById("modal-location-info").classList.add("hidden");
}

async function deleteWorkflow(workflowId, locationId, name) {
    const ok = await appConfirm({
        title: "Eliminar workflow",
        message: `¿Eliminar permanentemente el workflow "${name}" en Bucéfalo?\n\nGHL lo mueve a "Eliminado" durante 30 días y luego lo borra para siempre.`,
        confirmText: "Eliminar",
        severity: "danger",
    });
    if (!ok) return;
    showToast("Disparando eliminación...", "info");
    
    mutateFetch(`/api/workflows/${workflowId}?location_id=${locationId}`, { method: "DELETE" })
    .then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); })
    .then(data => {
        showToast("Proceso de eliminación en curso.", "success");
        if (data.task_id) monitorPlaywrightTask(data.task_id);
    })
    .catch(err => showToast(err.message, "error"));
}

function monitorPlaywrightTask(taskId) {
    // Redirigir suavemente al usuario a la pestaña de terminal de scripts.
    const scriptTabBtn = document.querySelector('.tab-link[data-tab="tab-scripts"]');
    if (scriptTabBtn) scriptTabBtn.click();

    const term = ensureLiveTerminal();
    if (!term) return;

    // Cerrar streams anteriores y limpiar buffer para el nuevo run.
    if (window.activeEventSource) {
        try { window.activeEventSource.close(); } catch (e) {}
        window.activeEventSource = null;
    }
    term.detach();
    term.reset();

    setTerminalState("running", "Ejecutando");
    window.activeRunContext = { scriptName: "playwright", taskId: taskId };
    term.appendSystem(`[SISTEMA]: Conectando con logs de Playwright (Task ID: ${taskId})...`, "info");

    // Playwright también puede generar logs pesados (sesiones largas, OTPs, etc.).
    // Reusa el mismo bar sticky con contador. Playwright no tiene controles de
    // run (pause/resume/stop/rollback), así que mountRunControls lo deja vacío.
    setRunBarVisible(true, taskId);
    mountRunControls(taskId, false);

    const eventSource = term.attachStream(taskId);
    window.activeEventSource = eventSource;

    term.onEof = () => {
        term.appendSystem("[SISTEMA]: Ejecución de Playwright completada con éxito.", "info");
        window.activeEventSource = null;
        setTerminalState("success", "Completado");
        showToast("Acción finalizada y sincronizada en SQLite.", "success");
        // Recargar tabla de workflows local.
        setTimeout(loadWorkflowsTable, 1000);
    };

    term.onStreamError = () => {
        term.detach();
        window.activeEventSource = null;
        setTerminalState("failed", "Falló");
    };
}

// ==========================================================================
// COMPARATIVA MARCA vs SUCURSALES
// ==========================================================================

let comparativaState = {
    data: null,
    loading: false,
    bound: false,
};

const BRAND_LOCATION_ID_UI = "GbKkBpCmKu2QmloKFHy3";
const BUCEFALO_BASE_URL = "https://crm.bucefalocrm.io";

function contactLink(name, contactId, locationId) {
    const safeName = escapeHtml(name || "(sin nombre)");
    if (!contactId || !locationId) return safeName;
    const url = `${BUCEFALO_BASE_URL}/v2/location/${locationId}/contacts/detail/${contactId}`;
    return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir contacto en Bucéfalo">${safeName}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a>`;
}

function branchLink(name, locationId) {
    const safeName = escapeHtml(name || "—");
    if (!name) return safeName;
    if (!locationId) return safeName;
    // Bucefalo cambio el listado por defecto a la smart list "All" — sin ese
    // sufijo el link redirige a una pantalla intermedia/vacia en muchas cuentas.
    const url = `${BUCEFALO_BASE_URL}/v2/location/${locationId}/contacts/smart_list/All`;
    return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir sucursal en Bucéfalo">${safeName}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a>`;
}

// Pinta la celda "Posibles coincidencias" del bucket missing_in_assigned_branch.
// Recibe el array de fuzzy_matches que el audit produce con modalidades permisivas
// (teléfono parcial, email canónico/local, primer+apellido). Si está vacío,
// devuelve un guion; si tiene matches, muestra advertencia + lista expandible
// con enlace directo a cada contacto en su sucursal en Bucéfalo.
function renderFuzzyMatchesCell(fuzzyMatches) {
    if (!Array.isArray(fuzzyMatches) || fuzzyMatches.length === 0) {
        return `<span class="text-muted" style="font-size: 11px;">—</span>`;
    }
    const STRATEGY_BADGE = {
        "phone_partial": { label: "TEL~", title: "Teléfono parcial (últimos 7 dígitos)", cls: "fuzzy-badge-phone" },
        "email_canonical": { label: "MAIL=", title: "Mismo email canónico (gmail sin puntos/alias)", cls: "fuzzy-badge-email-strong" },
        "email_local": { label: "MAIL~", title: "Misma parte local del email en otro dominio", cls: "fuzzy-badge-email" },
        "first_last": { label: "NOM~", title: "Mismo nombre + primer apellido", cls: "fuzzy-badge-name" },
    };
    const count = fuzzyMatches.length;
    const itemsHtml = fuzzyMatches.map(fm => {
        const badge = STRATEGY_BADGE[fm.strategy] || { label: fm.strategy || "?", title: fm.strategy_label || "", cls: "" };
        const fullName = `${fm.first_name || ""} ${fm.last_name || ""}`.trim() || "(sin nombre)";
        const branchName = fm.location_name || fm.location_id || "—";
        const branchTienda = fm.location_tienda ? ` <span class="text-muted">(TIENDA ${escapeHtml(fm.location_tienda)})</span>` : "";
        const contactUrl = (fm.id && fm.location_id)
            ? `${BUCEFALO_BASE_URL}/v2/location/${fm.location_id}/contacts/detail/${fm.id}`
            : null;
        const dataParts = [fm.phone, fm.email].filter(Boolean).map(escapeHtml).join(" · ");
        const link = contactUrl
            ? `<a href="${contactUrl}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir este contacto en su sucursal">${escapeHtml(fullName)}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a>`
            : `<span>${escapeHtml(fullName)}</span>`;
        return `
            <li class="fuzzy-match-item">
                <span class="fuzzy-badge ${badge.cls}" title="${escapeHtmlAttr(badge.title)}">${escapeHtml(badge.label)}</span>
                ${link}
                <div class="fuzzy-match-meta">
                    <span class="fuzzy-match-branch">${escapeHtml(branchName)}${branchTienda}</span>
                    ${dataParts ? `<span class="text-muted" style="font-size: 11px;">${dataParts}</span>` : ""}
                </div>
            </li>
        `;
    }).join("");
    return `
        <details class="fuzzy-matches-cell">
            <summary class="fuzzy-matches-summary" title="El escaneo encontró ${count} contacto(s) parecido(s) en otra(s) sucursal(es). Revisa antes de crear: podría ser el mismo contacto registrado con datos ligeramente distintos.">
                <i class="fa-solid fa-triangle-exclamation"></i>
                ${count} posible${count === 1 ? "" : "s"} coincidencia${count === 1 ? "" : "s"}
            </summary>
            <ul class="fuzzy-matches-list">${itemsHtml}</ul>
        </details>
    `;
}

const COMPARATIVA_BUCKET_COLUMNS = {
    missing_in_brand: [
        // Contacto de sucursal: id = item.id, location = item.branch_location_id
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, it.branch_location_id) },
        { key: "phone", label: "Teléfono" },
        { key: "email", label: "Email" },
        { key: "branch_name", label: "Sucursal" },
        { key: "opps_in_branch", label: "Opps locales", align: "right" },
    ],
    missing_in_assigned_branch: [
        {
            key: "_bulk_select",
            label: "",
            // Master checkbox vive en el header de la tabla: marca/desmarca todas
            // las filas visibles en bloque. Es la unica casilla de control.
            headerRender: (uiKey) => `<input type="checkbox" class="comp-bulk-master-checkbox" data-bucket="${uiKey}" title="Seleccionar/deseleccionar todos los visibles">`,
            render: (item) => {
                const expectedLoc = item.expected_location_id || "";
                const id = item.id || "";
                if (!expectedLoc || !id) {
                    // Sin sucursal resuelta o sin id no es seleccionable para bulk-create.
                    return `<input type="checkbox" class="comp-bulk-row-checkbox" disabled title="No se pudo resolver la sucursal destino — no es seleccionable para crear en bulk.">`;
                }
                // Preserva la marca si el id ya estaba seleccionado antes del re-render.
                const isChecked = (typeof comparativaBulkSelection !== "undefined"
                    && comparativaBulkSelection["missing_in_assigned_branch"]
                    && comparativaBulkSelection["missing_in_assigned_branch"].has(id));
                const checkedAttr = isChecked ? " checked" : "";
                return `<input type="checkbox" class="comp-bulk-row-checkbox" data-bucket="missing_in_assigned_branch" data-brand-contact-id="${escapeHtmlAttr(id)}" data-expected-location-id="${escapeHtmlAttr(expectedLoc)}" data-name="${escapeHtmlAttr(item.name || "")}" title="Seleccionar para crear en bulk"${checkedAttr}>`;
            },
        },
        {
            key: "_create_in_branch",
            label: "",
            render: (item) => {
                const expectedLoc = item.expected_location_id || "";
                const expectedName = item.expected_branch_name || "";
                if (!expectedLoc) {
                    return `<button class="btn-micro-create-in-branch is-disabled" disabled title="No se pudo resolver la sucursal esperada de este contacto."><i class="fa-solid fa-user-plus"></i></button>`;
                }
                return `<button class="btn-micro-create-in-branch" data-action="create-in-expected-branch" data-brand-contact-id="${escapeHtmlAttr(item.id || "")}" data-expected-location-id="${escapeHtmlAttr(expectedLoc)}" data-expected-branch-name="${escapeHtmlAttr(expectedName)}" data-name="${escapeHtmlAttr(item.name || "")}" title="Crear este contacto en ${escapeHtmlAttr(expectedName || expectedLoc)} con todos sus custom fields mapeados."><i class="fa-solid fa-user-plus"></i></button>`;
            },
        },
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) },
        { key: "phone", label: "Teléfono" },
        { key: "email", label: "Email" },
        { key: "tienda", label: "TIENDA" },
        { key: "expected_branch_name", label: "Sucursal esperada", render: it => branchLink(it.expected_branch_name, it.expected_location_id) },
        { key: "opps_in_brand", label: "Opps en Marca", align: "right" },
        {
            key: "_fuzzy_matches",
            label: "Posibles coincidencias",
            render: (item) => renderFuzzyMatchesCell(item.fuzzy_matches || []),
        },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                const cand = item.branch_target_candidate;
                const cnt = item.branch_target_candidates_count || 0;
                if (!cand) {
                    const reason = cnt > 1
                        ? `Hay ${cnt} contactos con el mismo nombre en la sucursal asignada — ambiguo, no se puede actualizar automáticamente.`
                        : "No existe un contacto con el mismo nombre en la sucursal asignada.";
                    return `<button class="btn-micro-sync-from-branch is-disabled" disabled title="${escapeHtml(reason)}"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>`;
                }
                return `<button class="btn-micro-sync-from-branch" data-action="update-branch-from-brand" data-brand-contact-id="${escapeHtml(item.id || "")}" data-branch-contact-id="${escapeHtml(cand.id || "")}" data-branch-location-id="${escapeHtml(item.expected_location_id || "")}" data-branch-name="${escapeHtml(item.expected_branch_name || "")}" data-name="${escapeHtml(item.name || "")}" title="Actualizar el contacto en ${escapeHtml(item.expected_branch_name || "la sucursal asignada")} con los datos y custom fields de Marca."><i class="fa-solid fa-arrow-right-arrow-left"></i></button>`;
            },
        },
    ],
    present_in_other_branch_not_assigned: [
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) },
        { key: "tienda", label: "TIENDA actual" },
        { key: "expected_branch_name", label: "Sucursal esperada", render: it => branchLink(it.expected_branch_name, it.expected_location_id) },
        {
            key: "_real_branches",
            label: "Está realmente en",
            render: (it) => {
                const branches = it.other_branches || [];
                return branches.map(b => `<div style="font-size: 12px; line-height: 1.5;">${escapeHtml(b.name || b.location_id)} <span class="text-muted">· TIENDA: ${escapeHtml(b.tienda_value || "—")}</span> <a href="${BUCEFALO_BASE_URL}/v2/location/${b.location_id}/contacts/detail/${b.id}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir contacto en sucursal"><i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a></div>`).join("");
            },
        },
        { key: "opps_in_brand", label: "Opps Marca", align: "right" },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                const branches = item.other_branches || [];
                if (!branches.length) return "";
                // Pasar JSON de branches en data-attr para que el handler los reciba.
                const branchesAttr = escapeHtml(JSON.stringify(branches.map(b => ({
                    location_id: b.location_id, name: b.name, tienda_value: b.tienda_value, id: b.id
                }))));
                return `<button class="btn-micro-sync-from-branch" data-action="update-brand-tienda" data-brand-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-branches='${branchesAttr}' title="Cambiar la TIENDA del contacto Marca para que apunte a la sucursal donde realmente está."><i class="fa-solid fa-shuffle"></i></button>`;
            },
        },
    ],
    probable_duplicate_in_brand: [
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) },
        { key: "phone", label: "Tel. en Marca" },
        { key: "email", label: "Email en Marca" },
        { key: "tienda", label: "TIENDA" },
        { key: "expected_branch_name", label: "Sucursal asignada", render: it => branchLink(it.expected_branch_name, it.expected_location_id) },
        {
            key: "_branch_existing",
            label: "Contacto bueno en sucursal",
            render: (it) => {
                const ex = it.branch_existing_contact || {};
                if (!ex.id) return "—";
                const fullName = `${ex.first_name || ""} ${ex.last_name || ""}`.trim() || "(sin nombre)";
                const tel = ex.phone || "—";
                const mail = ex.email || "—";
                const link = `${BUCEFALO_BASE_URL}/v2/location/${it.expected_location_id}/contacts/detail/${ex.id}`;
                return `<div style="font-size: 12px; line-height: 1.5;"><a href="${link}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir contacto en sucursal">${escapeHtml(fullName)}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a><br><span class="text-muted">tel: ${escapeHtml(tel)} · email: ${escapeHtml(mail)}</span></div>`;
            },
        },
        { key: "opps_in_brand", label: "Opps en Marca", align: "right" },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                return `<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtmlAttr(item.id || "")}" data-name="${escapeHtmlAttr(item.name || "")}" data-opps="${item.opps_in_brand || 0}" title="Eliminar este duplicado en Marca (irreversible). El contacto bueno permanece en sucursal."><i class="fa-solid fa-trash"></i></button>`;
            },
        },
    ],
    brand_not_in_any_branch: [
        // Contacto de Marca
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) },
        { key: "phone", label: "Teléfono" },
        { key: "email", label: "Email" },
        { key: "tienda", label: "TIENDA" },
        { key: "sucursal", label: "Sucursal (CF)" },
        {
            key: "expected_branch_name",
            label: "Sucursal esperada",
            render: it => {
                const name = it.expected_branch_name;
                if (!name) return "—";
                const src = it.resolution_source;
                let badge = "";
                if (src === "tienda") {
                    badge = ` <span class="resolution-badge resolution-tienda" title="Resuelto por campo TIENDA">T</span>`;
                } else if (src === "sucursal_exact") {
                    badge = ` <span class="resolution-badge resolution-sucursal-exact" title="Resuelto por Sucursal (match exacto)">S</span>`;
                } else if (src === "sucursal_substring") {
                    badge = ` <span class="resolution-badge resolution-sucursal-substring" title="Resuelto por Sucursal (substring approx) — verifica que sea el correcto">S~</span>`;
                }
                return `${escapeHtml(name)}${badge}`;
            },
        },
        { key: "opps_in_brand", label: "Opps", align: "right", format: v => v == null ? "0" : String(v) },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                const expectedLoc = item.expected_location_id || "";
                const expectedName = item.expected_branch_name || "";
                // Solo mostramos crear/verificar si sabemos a qué sucursal apunta.
                let actionBtns = "";
                if (expectedLoc) {
                    actionBtns += `<button class="btn-micro-verify" data-action="verify-in-expected-branch"
                        data-brand-contact-id="${escapeHtml(item.id || "")}"
                        data-expected-location-id="${escapeHtml(expectedLoc)}"
                        data-expected-branch-name="${escapeHtml(expectedName)}"
                        data-name="${escapeHtml(item.name || "")}"
                        title="Verificar en SQLite si ya existe un homónimo EXACTO en ${escapeHtml(expectedName || expectedLoc)}. Read-only."><i class="fa-solid fa-magnifying-glass"></i></button>`;
                    actionBtns += `<button class="btn-micro-create-in-branch" data-action="create-in-expected-branch"
                        data-brand-contact-id="${escapeHtml(item.id || "")}"
                        data-expected-location-id="${escapeHtml(expectedLoc)}"
                        data-expected-branch-name="${escapeHtml(expectedName)}"
                        data-name="${escapeHtml(item.name || "")}"
                        title="Crear este contacto en ${escapeHtml(expectedName || expectedLoc)} con todos sus custom fields mapeados."><i class="fa-solid fa-user-plus"></i></button>`;
                }
                return `${actionBtns}<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-opps="${item.opps_in_brand || 0}" title="Eliminar este contacto en Marca (irreversible)"><i class="fa-solid fa-trash"></i></button>`;
            },
        },
    ],
    brand_without_tienda: [
        {
            key: "name",
            label: "Nombre",
            render: it => {
                const testBadge = it.looks_like_test
                    ? ` <span class="resolution-badge resolution-test" title="Detectado como contacto de prueba/test">TEST</span>`
                    : "";
                return `${contactLink(it.name, it.id, BRAND_LOCATION_ID_UI)}${testBadge}`;
            },
        },
        { key: "phone", label: "Teléfono" },
        { key: "email", label: "Email" },
        { key: "sucursal", label: "Sucursal (CF)", render: it => escapeHtml(it.sucursal || "—") },
        {
            key: "expected_branch_name",
            label: "Sucursal esperada",
            render: it => {
                if (!it.expected_branch_name) {
                    return it.sucursal ? `<span class="text-muted">no resuelta</span>` : `<span class="text-muted">—</span>`;
                }
                const kind = it.sucursal_resolution_kind;
                const badge = kind === "exact"
                    ? ` <span class="resolution-badge resolution-sucursal-exact" title="Match exacto del campo Sucursal">S</span>`
                    : kind === "substring"
                        ? ` <span class="resolution-badge resolution-sucursal-substring" title="Match por substring — verifica que sea el correcto">S~</span>`
                        : "";
                return `${escapeHtml(it.expected_branch_name)}${badge}`;
            },
        },
        { key: "expected_tienda", label: "TIENDA a escribir", render: it => it.expected_tienda ? escapeHtml(it.expected_tienda) : `<span class="text-muted">—</span>` },
    ],
    brand_unknown_tienda: [
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) },
        { key: "phone", label: "Teléfono" },
        { key: "email", label: "Email" },
        { key: "tienda", label: "TIENDA" },
    ],
    missing_opps_in_brand: [
        { key: "name", label: "Oportunidad" },
        { key: "status", label: "Estado" },
        { key: "monetary_value", label: "Valor", align: "right", format: v => v ? `$${Number(v).toLocaleString("es-MX", { maximumFractionDigits: 2 })}` : "—" },
        // Contacto de sucursal: contact_id + branch_location_id
        { key: "contact_name", label: "Contacto", render: it => contactLink(it.contact_name, it.contact_id, it.branch_location_id) },
        { key: "contact_phone", label: "Tel." },
        { key: "branch_name", label: "Sucursal" },
        { key: "reason", label: "Motivo" },
    ],
    opps_missing_id_field: [
        {
            key: "location_name",
            label: "Cuenta",
            render: it => {
                const tag = it.is_brand
                    ? `<span class="resolution-badge resolution-test" title="Solo informativo: en Marca este campo se resuelve por matcheo/sync">MARCA</span>`
                    : `<span class="resolution-badge resolution-sucursal-substring">SUC</span>`;
                return `${tag} ${escapeHtml(it.location_name || "")}`;
            },
        },
        { key: "name", label: "Oportunidad", render: it => contactLink(it.name || "(sin nombre)", it.id, it.location_id) },
        { key: "status", label: "Estado" },
        {
            key: "field_value",
            label: "Valor del campo",
            render: it => {
                if (!it.field_value) {
                    return `<span class="resolution-badge resolution-test">vacío</span>`;
                }
                const len = Number(it.field_len || (it.field_value || "").length);
                const cls = (len === 20) ? "" : "resolution-test";
                const badge = cls ? ` <span class="resolution-badge ${cls}">len ${len}</span>` : "";
                return `<code>${escapeHtml(String(it.field_value))}</code>${badge}`;
            },
        },
        { key: "reason", label: "Motivo" },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                if (item.is_brand) {
                    return `<button class="btn-micro-match-brand-opp-id" data-action="match-brand-opp-id" data-opp-id="${escapeHtml(item.id || "")}" title="Matchear esta opp con su contraparte en sucursal y poblar el campo con el id de la opp de sucursal"><i class="fa-solid fa-arrows-left-right-to-line"></i></button>`;
                }
                return `<button class="btn-micro-fill-opp-id" data-action="fill-opp-id" data-opp-id="${escapeHtml(item.id || "")}" data-location-id="${escapeHtml(item.location_id || "")}" title="Llenar el campo con el id nativo de esta opp"><i class="fa-solid fa-wand-magic-sparkles"></i></button>`;
            },
        },
    ],
    contacts_missing_id_field: [
        {
            key: "location_name",
            label: "Cuenta",
            render: it => {
                const tag = it.is_brand
                    ? `<span class="resolution-badge resolution-test" title="Solo informativo: en Marca este campo se resuelve por matcheo/sync">MARCA</span>`
                    : `<span class="resolution-badge resolution-sucursal-substring">SUC</span>`;
                return `${tag} ${escapeHtml(it.location_name || "")}`;
            },
        },
        {
            key: "name",
            label: "Contacto",
            render: it => {
                const full = `${(it.first_name || "").trim()} ${(it.last_name || "").trim()}`.trim() || "(sin nombre)";
                return contactLink(full, it.id, it.location_id);
            },
        },
        { key: "phone", label: "Tel." },
        { key: "email", label: "Email" },
        {
            key: "field_value",
            label: "Valor del campo",
            render: it => {
                if (!it.field_value) {
                    return `<span class="resolution-badge resolution-test">vacío</span>`;
                }
                const len = Number(it.field_len || (it.field_value || "").length);
                const cls = (len === 20) ? "" : "resolution-test";
                const badge = cls ? ` <span class="resolution-badge ${cls}">len ${len}</span>` : "";
                return `<code>${escapeHtml(String(it.field_value))}</code>${badge}`;
            },
        },
        { key: "reason", label: "Motivo" },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                if (item.is_brand) {
                    return `<span class="text-secondary" title="En Marca el campo se llena por matcheo/sync, no manualmente">(solo matcheo)</span>`;
                }
                return `<button class="btn-micro-fill-contact-id" data-action="fill-contact-id" data-contact-id="${escapeHtml(item.id || "")}" data-location-id="${escapeHtml(item.location_id || "")}" title="Llenar el campo con el id nativo de este contacto"><i class="fa-solid fa-wand-magic-sparkles"></i></button>`;
            },
        },
    ],
    intra_brand_duplicates: [
        { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) },
        { key: "group_size", label: "Grupo", align: "right", render: it => `<span class="group-count" data-name-norm="${escapeHtml(it.name_norm || "")}">${escapeHtml(String(it.group_size || ""))} con mismo nombre</span>` },
        { key: "opps_in_brand", label: "Opps", align: "right" },
        { key: "date_added", label: "Creado", format: v => v ? new Date(v).toLocaleString("es-MX", { dateStyle: "short", timeStyle: "short" }) : "—" },
        {
            key: "_actions",
            label: "",
            align: "right",
            render: (item) => {
                const candidates = item.branch_candidates || [];
                const candidatesAttr = candidates.length ? ` data-branch-candidates='${escapeHtml(JSON.stringify(candidates))}'` : "";
                const syncBtn = candidates.length
                    ? `<button class="btn-micro-sync-from-branch" data-action="sync-from-branch" data-brand-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-name-norm="${escapeHtml(item.name_norm || "")}"${candidatesAttr} title="Sincronizar desde sucursal (solo disponible cuando este sea el único restante del grupo)" hidden><i class="fa-solid fa-arrow-right-arrow-left"></i></button>`
                    : "";
                return `${syncBtn}<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-name-norm="${escapeHtml(item.name_norm || "")}" data-opps="${item.opps_in_brand || 0}" title="Eliminar este duplicado (irreversible)"><i class="fa-solid fa-trash"></i></button>`;
            },
        },
    ],
    brand_duplicate_link_opps: [
        {
            key: "recommended_action",
            label: "",
            render: it => it.recommended_action === "keep"
                ? `<span class="resolution-badge resolution-sucursal-exact" title="Réplica canónica a conservar (el limpiador confirma por createdAt en vivo)">CONSERVAR</span>`
                : `<span class="resolution-badge resolution-test" title="Réplica sobrante a eliminar">BORRAR</span>`,
        },
        { key: "name", label: "Oportunidad", render: it => contactLink(it.name || "(sin nombre)", it.id, BRAND_LOCATION_ID_UI) },
        { key: "status", label: "Estado" },
        { key: "monetary_value", label: "Valor", align: "right", format: v => v ? `$${Number(v).toLocaleString("es-MX", { maximumFractionDigits: 2 })}` : "—" },
        { key: "contact_name", label: "Contacto", render: it => contactLink(it.contact_name, it.contact_id, BRAND_LOCATION_ID_UI) },
        {
            key: "link_value",
            label: "ID Opp Sucursal (compartido)",
            render: it => `<code>${escapeHtml(String(it.link_value || ""))}</code>`,
        },
        { key: "group_size", label: "Grupo", align: "right", render: it => `${escapeHtml(String(it.group_size || ""))} apuntan al mismo` },
        { key: "branch_name", label: "Origen (sucursal)" },
    ],
};

const COMPARATIVA_BUCKET_TO_API_KEY = {
    missing_in_brand: "contacts_in_branch_not_in_brand",
    missing_in_assigned_branch: "contacts_in_brand_not_in_assigned_branch",
    present_in_other_branch_not_assigned: "contacts_in_brand_present_in_other_branch_not_assigned",
    probable_duplicate_in_brand: "contacts_in_brand_probable_duplicate",
    brand_without_tienda: "contacts_in_brand_without_tienda",
    brand_unknown_tienda: "contacts_in_brand_with_unknown_tienda",
    brand_not_in_any_branch: "contacts_in_brand_not_in_any_branch",
    missing_opps_in_brand: "opportunities_in_branch_not_in_brand",
    opps_missing_id_field: "opportunities_missing_id_field",
    brand_duplicate_link_opps: "opportunities_in_brand_duplicate_link",
    contacts_missing_id_field: "contacts_missing_id_field",
    intra_brand_duplicates: "intra_brand_duplicates",
};

function bindComparativaEvents() {
    if (comparativaState.bound) return;
    const reloadBtn = document.getElementById("btn-comparativa-reload");
    if (reloadBtn) {
        reloadBtn.addEventListener("click", () => loadComparativaMarcaSucursales(true));
    }
    const retryBtn = document.getElementById("comparativa-status-retry");
    if (retryBtn) {
        retryBtn.addEventListener("click", () => loadComparativaMarcaSucursales(true));
    }
    document.querySelectorAll(".btn-comparativa-toggle").forEach(btn => {
        btn.addEventListener("click", () => toggleComparativaBucket(btn.dataset.bucket));
    });
    document.querySelectorAll(".btn-comparativa-export").forEach(btn => {
        btn.addEventListener("click", () => exportComparativaBucket(btn.dataset.bucket));
    });
    comparativaState.bound = true;
}

// ---- Estados del banner de la comparativa ----
// Tres estados visuales para que el usuario nunca quede mirando "—" sin saber
// si está cargando o si falló. El banner vive arriba de las tarjetas de totales
// para que sea imposible no verlo.
function setComparativaBanner(state, msg) {
    const banner = document.getElementById("comparativa-status-banner");
    const icon = banner ? banner.querySelector(".comparativa-status-icon") : null;
    const title = document.getElementById("comparativa-status-title");
    const message = document.getElementById("comparativa-status-msg");
    const retry = document.getElementById("comparativa-status-retry");
    if (!banner) return;
    banner.classList.remove("is-error");
    if (state === "loading") {
        banner.style.display = "flex";
        if (icon) icon.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i>`;
        if (title) title.textContent = "Calculando comparativa…";
        if (message) message.textContent = msg || "Esto puede tomar unos segundos en bases grandes.";
        if (retry) retry.style.display = "none";
    } else if (state === "error") {
        banner.style.display = "flex";
        banner.classList.add("is-error");
        if (icon) icon.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i>`;
        if (title) title.textContent = "No se pudo cargar la comparativa";
        if (message) message.textContent = msg || "Error desconocido. Revisa que haya datos sincronizados.";
        if (retry) retry.style.display = "";
    } else {
        banner.style.display = "none";
    }
}

// Resetea los valores numéricos al estado de placeholder ("—") y limpia los
// badges/desglose. Se llama al iniciar una carga y al fallar el render, para
// que un error parcial no deje la UI con datos mezclados (estado viejo +
// estado nuevo) que es exactamente lo que confundía al usuario.
function resetComparativaUI(label) {
    const dash = "—";
    comparativaSetText("comp-brand-contacts", dash);
    comparativaSetText("comp-brand-name", label || "");
    comparativaSetText("comp-branches-contacts", dash);
    comparativaSetText("comp-branches-count", dash);
    comparativaSetText("comp-brand-opps", dash);
    comparativaSetText("comp-branches-opps", dash);
    comparativaSetText("comp-contacts-diff", dash);
    comparativaSetText("comp-opps-diff", dash);
    comparativaSetText("comp-contacts-diff-status", "Marca − Sucursales");
    comparativaSetText("comp-opps-diff-status", "Marca − Sucursales");
    Object.keys(COMPARATIVA_BUCKET_TO_API_KEY).forEach(uiKey => {
        const badge = document.getElementById(`comp-bucket-${uiKey}`);
        if (badge) badge.textContent = dash;
        const body = document.getElementById(`comp-body-${uiKey}`);
        if (body) {
            body.dataset.rendered = "false";
            body.innerHTML = "";
        }
    });
}

async function loadComparativaMarcaSucursales(force = false) {
    bindComparativaEvents();
    if (comparativaState.loading) return;
    if (!force && comparativaState.data) {
        renderComparativa(comparativaState.data);
        return;
    }
    comparativaState.loading = true;
    const reloadBtn = document.getElementById("btn-comparativa-reload");
    if (reloadBtn) {
        reloadBtn.disabled = true;
        reloadBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Calculando...`;
    }
    resetComparativaUI("Cargando…");
    setComparativaBanner("loading");
    setComparativaPlaceholder("Calculando comparativa... esto puede tomar unos segundos en bases grandes.");

    try {
        const res = await fetch(`/api/comparativa/marca-vs-sucursales?limit_missing=2000`);
        if (!res.ok) {
            let detail = res.statusText;
            try {
                const err = await res.json();
                detail = err && err.detail ? err.detail : detail;
            } catch (_) { /* respuesta no-JSON */ }
            throw new Error(detail || `HTTP ${res.status}`);
        }
        const data = await res.json();
        if (!data || !data.totals || !data.totals.brand || !data.totals.branches_aggregate || !data.totals.diff) {
            throw new Error("La API devolvió un formato inesperado. ¿La base de datos está sincronizada?");
        }
        comparativaState.data = data;
        try {
            renderComparativa(data);
        } catch (renderErr) {
            console.error("renderComparativa falló:", renderErr);
            resetComparativaUI("Error al pintar");
            throw new Error(`Error al pintar la comparativa: ${renderErr.message}`);
        }
        setComparativaBanner("hidden");
        const stamp = document.getElementById("comparativa-last-run");
        if (stamp) stamp.textContent = `Calculado: ${new Date().toLocaleString("es-MX")}`;
    } catch (e) {
        resetComparativaUI("Sin datos");
        setComparativaBanner("error", e.message);
        setComparativaPlaceholder(`Error al cargar la comparativa: ${e.message}`);
    } finally {
        comparativaState.loading = false;
        if (reloadBtn) {
            reloadBtn.disabled = false;
            reloadBtn.innerHTML = `<i class="fa-solid fa-rotate"></i> Recalcular`;
        }
    }
}

function setComparativaPlaceholder(msg) {
    const tbody = document.getElementById("comp-per-branch-rows");
    if (tbody) tbody.innerHTML = `<tr><td colspan="4" class="text-center">${msg}</td></tr>`;
}

function fmtNumber(n) {
    return Number(n || 0).toLocaleString("es-MX");
}

function renderComparativa(data) {
    const t = data.totals || {};
    const b = t.brand || {};
    const ag = t.branches_aggregate || {};
    const d = t.diff || {};

    // Totales
    comparativaSetText("comp-brand-contacts", fmtNumber(b.contacts));
    comparativaSetText("comp-brand-name", b.name || "—");
    comparativaSetText("comp-branches-contacts", fmtNumber(ag.contacts));
    comparativaSetText("comp-branches-count", `${ag.branch_count || 0} sucursales activas`);
    comparativaSetText("comp-brand-opps", fmtNumber(b.opportunities));
    comparativaSetText("comp-branches-opps", fmtNumber(ag.opportunities));

    const cDiff = Number(d.contacts || 0);
    const oDiff = Number(d.opportunities || 0);
    comparativaSetText("comp-contacts-diff", (cDiff > 0 ? "+" : "") + fmtNumber(cDiff));
    comparativaSetText("comp-opps-diff", (oDiff > 0 ? "+" : "") + fmtNumber(oDiff));
    comparativaSetText("comp-contacts-diff-status", d.contacts_match ? "OK · totales coinciden" : "Descuadre detectado");
    comparativaSetText("comp-opps-diff-status", d.opportunities_match ? "OK · totales coinciden" : "Descuadre detectado");

    const cCard = document.getElementById("comp-contacts-diff-card");
    const oCard = document.getElementById("comp-opps-diff-card");
    if (cCard) cCard.classList.toggle("is-ok", d.contacts_match);
    if (cCard) cCard.classList.toggle("is-mismatch", !d.contacts_match);
    if (oCard) oCard.classList.toggle("is-ok", d.opportunities_match);
    if (oCard) oCard.classList.toggle("is-mismatch", !d.opportunities_match);

    // Demos excluidas
    const demos = data.demos_excluded || [];
    const demosCard = document.getElementById("comp-demos-card");
    const demosList = document.getElementById("comp-demos-list");
    if (demos.length && demosCard && demosList) {
        demosCard.style.display = "";
        demosList.innerHTML = demos
            .map(dx => `<li><strong>${escapeHtml(dx.name)}</strong> <span class="text-muted">(${dx.location_id})</span></li>`)
            .join("");
    } else if (demosCard) {
        demosCard.style.display = "none";
    }

    // Buckets
    Object.entries(COMPARATIVA_BUCKET_TO_API_KEY).forEach(([uiKey, apiKey]) => {
        const bucket = (data.missing || {})[apiKey] || { total: 0, items: [], truncated: false };
        const badge = document.getElementById(`comp-bucket-${uiKey}`);
        if (badge) badge.textContent = fmtNumber(bucket.total);
        const body = document.getElementById(`comp-body-${uiKey}`);
        if (body) {
            body.dataset.rendered = "false";
            body.innerHTML = "";
            // Si el bucket esta abierto, recalcula su contenido.
            if (!body.hasAttribute("hidden")) {
                renderComparativaBucketBody(uiKey);
            }
        }
    });

    // Desglose por sucursal
    const tbody = document.getElementById("comp-per-branch-rows");
    if (tbody) {
        const rows = data.per_branch || [];
        if (!rows.length) {
            tbody.innerHTML = `<tr><td colspan="4" class="text-center">No hay sucursales para mostrar.</td></tr>`;
        } else {
            tbody.innerHTML = rows.map((r, idx) => `
                <tr>
                    <td>${idx + 1}</td>
                    <td>${escapeHtml(r.name)}</td>
                    <td style="text-align: right;">${fmtNumber(r.contacts)}</td>
                    <td style="text-align: right;">${fmtNumber(r.opportunities)}</td>
                </tr>
            `).join("");
        }
    }
}

function toggleComparativaBucket(uiKey) {
    const body = document.getElementById(`comp-body-${uiKey}`);
    if (!body) return;
    const open = body.hasAttribute("hidden");
    if (open) {
        body.removeAttribute("hidden");
        renderComparativaBucketBody(uiKey);
    } else {
        body.setAttribute("hidden", "");
    }
    // Cambiar icono del boton (toggle de chevron)
    const btn = document.querySelector(`.btn-comparativa-toggle[data-bucket="${uiKey}"]`);
    if (btn) {
        btn.innerHTML = open
            ? `<i class="fa-solid fa-chevron-up"></i> Ocultar listado`
            : `<i class="fa-solid fa-list"></i> Ver listado`;
    }
}

// ---- Bulk selection para buckets de Comparativa ----
// Mantiene el conjunto de brand_contact_ids seleccionados por bucket.
const comparativaBulkSelection = {
    // bucket_uiKey -> Set<brand_contact_id>
};

function getBulkSelectionSet(uiKey) {
    if (!comparativaBulkSelection[uiKey]) comparativaBulkSelection[uiKey] = new Set();
    return comparativaBulkSelection[uiKey];
}

function clearBulkSelection(uiKey) {
    comparativaBulkSelection[uiKey] = new Set();
}

function refreshBulkSelectionUI(uiKey) {
    const body = document.getElementById(`comp-body-${uiKey}`);
    if (!body) return;
    const sel = getBulkSelectionSet(uiKey);
    // Master checkbox (vive en el <th> de la primera columna): marcado si todas
    // las filas visibles seleccionables están en la selección; indeterminado
    // si hay marcadas parciales.
    const master = body.querySelector(`.comp-bulk-master-checkbox[data-bucket="${uiKey}"]`);
    const visibleSelectable = Array.from(body.querySelectorAll(`tbody tr`))
        .filter(tr => tr.style.display !== "none")
        .map(tr => tr.querySelector(`input.comp-bulk-row-checkbox:not([disabled])`))
        .filter(Boolean);
    if (master) {
        if (visibleSelectable.length === 0) {
            master.checked = false;
            master.indeterminate = false;
        } else {
            const checkedCount = visibleSelectable.filter(cb => cb.checked).length;
            master.checked = checkedCount === visibleSelectable.length;
            master.indeterminate = checkedCount > 0 && checkedCount < visibleSelectable.length;
        }
    }
    // Botón primario del header del bucket muestra el contador inline.
    const headerBtn = document.getElementById("btn-sync-missing-in-assigned-branch");
    if (headerBtn && uiKey === "missing_in_assigned_branch") {
        if (sel.size === 0) {
            headerBtn.disabled = true;
            headerBtn.innerHTML = `<i class="fa-solid fa-user-plus"></i> Crear seleccionados en sucursal`;
        } else {
            headerBtn.disabled = false;
            headerBtn.innerHTML = `<i class="fa-solid fa-user-plus"></i> Crear seleccionados en sucursal (${sel.size})`;
        }
    }
}

function bindBulkSelectionEvents(uiKey, body) {
    if (body.dataset.bulkBound === "true") return;
    body.dataset.bulkBound = "true";

    body.addEventListener("change", (e) => {
        const target = e.target;
        if (!target) return;
        // Checkbox por fila
        if (target.matches(`input.comp-bulk-row-checkbox[data-bucket="${uiKey}"]`)) {
            const id = target.dataset.brandContactId;
            if (!id) return;
            const sel = getBulkSelectionSet(uiKey);
            if (target.checked) sel.add(id);
            else sel.delete(id);
            refreshBulkSelectionUI(uiKey);
        }
        // Master checkbox (vive en el <th> de la columna de seleccion)
        if (target.matches(`input.comp-bulk-master-checkbox[data-bucket="${uiKey}"]`)) {
            const checked = target.checked;
            const sel = getBulkSelectionSet(uiKey);
            const visibleRows = Array.from(body.querySelectorAll(`tbody tr`))
                .filter(tr => tr.style.display !== "none");
            visibleRows.forEach(tr => {
                const cb = tr.querySelector(`input.comp-bulk-row-checkbox:not([disabled])`);
                if (!cb) return;
                cb.checked = checked;
                const id = cb.dataset.brandContactId;
                if (!id) return;
                if (checked) sel.add(id);
                else sel.delete(id);
            });
            refreshBulkSelectionUI(uiKey);
        }
    });

    // Estado inicial.
    refreshBulkSelectionUI(uiKey);
}

function renderComparativaBucketBody(uiKey) {
    const body = document.getElementById(`comp-body-${uiKey}`);
    if (!body) return;
    if (body.dataset.rendered === "true") return;
    const data = comparativaState.data;
    if (!data) {
        body.innerHTML = `<p class="text-secondary">Sin datos. Recalcula la comparativa.</p>`;
        return;
    }
    const apiKey = COMPARATIVA_BUCKET_TO_API_KEY[uiKey];
    const bucket = (data.missing || {})[apiKey] || { total: 0, items: [], truncated: false };
    const cols = COMPARATIVA_BUCKET_COLUMNS[uiKey] || [];

    if (!bucket.items.length) {
        body.innerHTML = `<p class="text-secondary" style="margin: 8px 0;">No hay registros en este grupo. ¡Limpio!</p>`;
        body.dataset.rendered = "true";
        return;
    }

    const truncatedNote = bucket.truncated
        ? `<p class="text-muted" style="margin: 0 0 8px 0; font-size: 12px;">Mostrando los primeros ${bucket.items.length} de ${bucket.total}. Exporta a CSV para verlos todos.</p>`
        : "";

    const filterId = `comp-filter-${uiKey}`;
    const mainTableHtml = `
        <div class="table-wrapper">
            <table class="data-table comparativa-bucket-table">
                <thead>
                    <tr>${cols.map(c => {
                        const thStyle = c.align ? ` style="text-align:${c.align}"` : "";
                        // Las columnas pueden definir headerRender(uiKey) para inyectar
                        // controles en el <th> (p.ej. master-checkbox de bulk select).
                        const thContent = (typeof c.headerRender === "function")
                            ? c.headerRender(uiKey)
                            : c.label;
                        return `<th${thStyle}>${thContent}</th>`;
                    }).join("")}</tr>
                </thead>
                <tbody>
                    ${bucket.items.map(item => `
                        <tr>${cols.map(c => {
                            const tdStyle = c.align ? ` style="text-align:${c.align}"` : "";
                            if (typeof c.render === "function") {
                                return `<td${tdStyle}>${c.render(item)}</td>`;
                            }
                            let v = item[c.key];
                            if (typeof c.format === "function") v = c.format(v);
                            if (v === undefined || v === null || v === "") v = "—";
                            const cell = escapeHtml(String(v));
                            return `<td${tdStyle}>${cell}</td>`;
                        }).join("")}</tr>
                    `).join("")}
                </tbody>
            </table>
        </div>
    `;

    // Para brand_without_tienda añadimos una sub-sección con los contactos detectados
    // como prueba/test. El delete reutiliza el endpoint existente
    // /api/comparativa/contact (mismo flujo que .btn-micro-delete[data-action=delete-brand-contact]).
    let testSectionHtml = "";
    if (uiKey === "brand_without_tienda") {
        const testItems = bucket.items.filter(it => it.looks_like_test);
        if (testItems.length > 0) {
            testSectionHtml = `
                <div class="comparativa-test-subsection">
                    <h5 class="comparativa-test-subsection-title">
                        <i class="fa-solid fa-flask-vial"></i>
                        Detectados como prueba/test
                        <span class="comparativa-test-count">${testItems.length}</span>
                    </h5>
                    <p class="text-secondary comparativa-test-subsection-desc">
                        Contactos cuyo nombre, email o teléfono contiene <code>test</code>, <code>testing</code>, <code>prueba</code> o <code>pruebas</code>. Elimínalos si confirmas que no son leads reales.
                    </p>
                    <div class="table-wrapper">
                        <table class="data-table comparativa-bucket-table">
                            <thead>
                                <tr>
                                    <th>Nombre</th>
                                    <th>Teléfono</th>
                                    <th>Email</th>
                                    <th>Sucursal (CF)</th>
                                    <th style="text-align:right;"></th>
                                </tr>
                            </thead>
                            <tbody>
                                ${testItems.map(it => `
                                    <tr>
                                        <td>${contactLink(it.name, it.id, BRAND_LOCATION_ID_UI)} <span class="resolution-badge resolution-test" title="Detectado como contacto de prueba/test">TEST</span></td>
                                        <td>${escapeHtml(it.phone || "—")}</td>
                                        <td>${escapeHtml(it.email || "—")}</td>
                                        <td>${escapeHtml(it.sucursal || "—")}</td>
                                        <td style="text-align:right;">
                                            <button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtml(it.id || "")}" data-name="${escapeHtml(it.name || "")}" data-opps="0" title="Eliminar este contacto de prueba (irreversible)">
                                                <i class="fa-solid fa-trash"></i>
                                            </button>
                                        </td>
                                    </tr>
                                `).join("")}
                            </tbody>
                        </table>
                    </div>
                </div>
            `;
        }
    }

    // Depuracion defensiva del set de seleccion: si los datos se refrescaron
    // (apply en otro bucket, reload manual), descarta IDs que ya no existen.
    if (uiKey === "missing_in_assigned_branch") {
        const sel = getBulkSelectionSet(uiKey);
        if (sel.size) {
            const stillExisting = new Set(bucket.items.map(it => it.id).filter(Boolean));
            Array.from(sel).forEach(id => { if (!stillExisting.has(id)) sel.delete(id); });
        }
    }

    body.innerHTML = `
        ${truncatedNote}
        <div class="comparativa-bucket-filter">
            <i class="fa-solid fa-magnifying-glass"></i>
            <input type="search" id="${filterId}" placeholder="Filtrar en este listado (nombre, teléfono, email, sucursal...)">
        </div>
        ${mainTableHtml}
        ${testSectionHtml}
    `;

    const input = document.getElementById(filterId);
    if (input) {
        input.addEventListener("input", () => {
            const term = input.value.trim().toLowerCase();
            body.querySelectorAll("tbody tr").forEach(tr => {
                tr.style.display = !term || tr.textContent.toLowerCase().includes(term) ? "" : "none";
            });
            // Si la fila se oculta, no entra como "visible" para el master-checkbox.
            refreshBulkSelectionUI(uiKey);
        });
    }

    // Listener delegado para botones de accion dentro del bucket body.
    // Solo bindeo una vez por body (dataset.actionsBound).
    if (body.dataset.actionsBound !== "true") {
        body.addEventListener("click", handleComparativaBucketAction);
        body.dataset.actionsBound = "true";
    }

    // Bindeo de bulk-selection solo para los buckets que la soportan.
    if (uiKey === "missing_in_assigned_branch") {
        bindBulkSelectionEvents(uiKey, body);
        // Re-render: sincroniza UI con el set en memoria (contador, master, botón header).
        refreshBulkSelectionUI(uiKey);
    }

    body.dataset.rendered = "true";
}

function updateIntraBrandDuplicateGroup(body, nameNorm) {
    if (!body || !nameNorm) return;
    const escSel = window.CSS && CSS.escape ? CSS.escape(nameNorm) : nameNorm.replace(/"/g, '\\"');
    const remainingBtns = body.querySelectorAll(`.btn-micro-delete[data-name-norm="${escSel}"]`);
    const trsInGroup = [];
    remainingBtns.forEach(b => { const tr = b.closest("tr"); if (tr) trsInGroup.push(tr); });
    const count = trsInGroup.length;

    trsInGroup.forEach(tr => {
        const groupSpan = tr.querySelector(".group-count");
        const btn = tr.querySelector(".btn-micro-delete");
        const syncBtn = tr.querySelector(".btn-micro-sync-from-branch");
        if (count <= 1) {
            if (groupSpan) {
                groupSpan.textContent = "Único restante";
                groupSpan.classList.add("group-count--last");
            }
            if (btn) {
                btn.disabled = true;
                btn.title = "Es el único restante del grupo — ya no es un duplicado";
                btn.classList.add("is-locked");
            }
            // Si hay candidatos sucursal, revelar el sync btn.
            if (syncBtn) {
                syncBtn.hidden = false;
                syncBtn.title = "Sincronizar desde sucursal: actualiza este contacto Marca con todos los datos y custom fields del contacto sucursal con mismo nombre.";
            }
        } else {
            if (groupSpan) {
                groupSpan.textContent = `${count} con mismo nombre`;
                groupSpan.classList.remove("group-count--last");
            }
            // Si vuelve a haber más, ocultar el sync btn.
            if (syncBtn) syncBtn.hidden = true;
        }
    });
}

async function handleUpdateBranchFromBrandClick(btn) {
    const brandContactId = btn.dataset.brandContactId;
    const branchContactId = btn.dataset.branchContactId;
    const branchLocationId = btn.dataset.branchLocationId;
    const branchName = btn.dataset.branchName || "(sucursal)";
    const name = btn.dataset.name || "(sin nombre)";

    const ok = await appConfirm({
        title: "Actualizar contacto en sucursal",
        severity: "warning",
        message: `Vas a actualizar el contacto <strong>${escapeHtmlAttr(name)}</strong> que está en <strong>${escapeHtmlAttr(branchName)}</strong> con los datos y custom fields del contacto Marca.<br><br>Esto pisa los campos del contacto en sucursal con los del Marca.`,
        confirmText: "Actualizar",
    });
    if (!ok) return;

    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';

    try {
        const res = await mutateFetch("/api/comparativa/update-branch-from-brand", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                brand_contact_id: brandContactId,
                branch_contact_id: branchContactId,
                branch_location_id: branchLocationId,
            }),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(err.detail || `HTTP ${res.status}`);
        }
        const data = await res.json();
        btn.innerHTML = '<i class="fa-solid fa-check"></i>';
        btn.title = `Actualizado: ${data.custom_fields_count} custom fields sincronizados.`;
        btn.classList.add("is-done");
        if (typeof comparativaState !== "undefined") comparativaState.data = null;
    } catch (err) {
        showToast(`Error: ${err.message}`, "error");
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}

/**
 * Verifica si el contacto Marca tiene un homónimo EXACTO en la sucursal esperada.
 * Read-only: solo consulta SQLite. Si match, refresca la tabla (el contacto
 * debería desaparecer del bucket brand_not_in_any_branch automáticamente).
 */
async function handleVerifyInExpectedBranchClick(btn) {
    const brandContactId = btn.dataset.brandContactId;
    const expectedLocationId = btn.dataset.expectedLocationId;
    const expectedBranchName = btn.dataset.expectedBranchName || expectedLocationId;
    const name = btn.dataset.name || "(sin nombre)";

    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';

    try {
        // Verify es read-only — usamos fetch directo (no necesita modo dry-run/live).
        const res = await fetch("/api/comparativa/verify-in-expected-branch", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ brand_contact_id: brandContactId, expected_location_id: expectedLocationId }),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(err.detail || `HTTP ${res.status}`);
        }
        const data = await res.json();

        if (data.found) {
            const matchesHtml = (data.matches || []).map(m =>
                `<li><strong>${escapeHtml(`${m.first_name || ""} ${m.last_name || ""}`.trim() || "(sin nombre)")}</strong> — ${escapeHtml(m.email || "sin email")} — ${escapeHtml(m.phone || "sin tel")}<br><code style="font-size: 10px;">${escapeHtml(m.id)}</code></li>`
            ).join("");
            await appConfirm({
                title: "Encontrado en la sucursal esperada",
                severity: "info",
                message: `
                    Se encontraron <strong>${data.match_count}</strong> match(es) EXACTO(s) para
                    <strong>${escapeHtml(name)}</strong> en <strong>${escapeHtml(expectedBranchName)}</strong>:
                    <ul style="margin: 8px 0 8px 18px;">${matchesHtml}</ul>
                    Esto significa que el audit Marca↔Sucursal probablemente no lo cruzó por phone/email distintos.
                    Al refrescar la comparativa, este contacto debería salir del bucket "sin contraparte".
                `,
                confirmText: "Refrescar comparativa",
                cancelText: "Cerrar",
            }).then(refresh => {
                if (refresh && typeof loadComparativaMarcaSucursales === "function") {
                    if (typeof comparativaState !== "undefined") comparativaState.data = null;
                    loadComparativaMarcaSucursales(true);
                }
            });
            btn.innerHTML = '<i class="fa-solid fa-check"></i>';
            btn.title = `Encontrado en ${expectedBranchName}.`;
            btn.classList.add("is-done");
        } else {
            await appConfirm({
                title: "Sin homónimo exacto",
                severity: "warning",
                message: `
                    No se encontró un contacto con nombre EXACTO "${escapeHtml(name)}" en
                    <strong>${escapeHtml(expectedBranchName)}</strong>.<br><br>
                    Posibles razones:
                    <ul style="margin: 6px 0 6px 18px; font-size: 12px;">
                        <li>El contacto efectivamente no existe en la sucursal.</li>
                        <li>El nombre difiere por mayúsculas, acentos o espacios (verify exige 100% byte por byte tras normalizar).</li>
                        <li>SQLite no está sincronizado con la sucursal — corre "Sincronizar Sucursal" y reintenta.</li>
                    </ul>
                `,
                confirmText: "Entendido",
                cancelText: "Cerrar",
            });
            btn.disabled = false;
            btn.innerHTML = originalHTML;
        }
    } catch (err) {
        showToast(`Error al verificar: ${err.message}`, "error");
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}

/**
 * Crea el contacto Marca en la sucursal esperada (POST contact con CFs mapeados).
 * Respeta el toggle global Dry-Run/Live via mutateFetch. Tras crear, refresca
 * la comparativa para que el contacto salga del bucket.
 */

/**
 * Click en "Llenar" (botón por-item) del bucket "opps_missing_id_field".
 * Abre el modal genérico de sync acotando a esa única opp para validar y aplicar.
 */
async function handleFillOppIdClick(btn) {
    const oppId = btn.dataset.oppId;
    if (!oppId) return;
    openSyncBucketModal("opps_missing_id_field", { contactIds: [oppId] });
}

/**
 * Click en "Matchear" (botón por-item para una opp Marca) del bucket
 * "opps_missing_id_field". Abre el modal genérico apuntando al bucket sintético
 * que rutea al endpoint /api/comparativa/match-brand-opp-id-sucursal.
 */
async function handleMatchBrandOppIdClick(btn) {
    const oppId = btn.dataset.oppId;
    if (!oppId) return;
    openSyncBucketModal("opps_missing_id_field_brand", { contactIds: [oppId] });
}

/**
 * Click en "Llenar" (botón por-item) del bucket "contacts_missing_id_field".
 */
async function handleFillContactIdClick(btn) {
    const contactId = btn.dataset.contactId;
    if (!contactId) return;
    openSyncBucketModal("contacts_missing_id_field", { contactIds: [contactId] });
}

async function handleCreateInExpectedBranchClick(btn) {
    const brandContactId = btn.dataset.brandContactId;
    const expectedLocationId = btn.dataset.expectedLocationId;
    const expectedBranchName = btn.dataset.expectedBranchName || expectedLocationId;
    const name = btn.dataset.name || "(sin nombre)";

    const isLive = window.AppMode.isLive;
    const ok = await appConfirm({
        title: isLive ? "Crear contacto en sucursal (LIVE)" : "Crear contacto en sucursal (Simulación)",
        severity: isLive ? "danger" : "info",
        message: `
            Vas a crear el contacto <strong>${escapeHtml(name)}</strong> en la sucursal
            <strong>${escapeHtml(expectedBranchName)}</strong> (${escapeHtml(expectedLocationId)}).<br><br>
            Se copian first/last name, email, phone y todos los custom fields mapeados por nombre
            desde el schema Marca al schema de la sucursal.<br><br>
            ${isLive
                ? "<strong>Atención:</strong> estás en modo LIVE. Se creará de verdad en Bucéfalo. Si ya existe homónimo por email/teléfono, GHL rechazará la creación."
                : "Estás en modo Simulación. Confirmar solo cierra este preview — nada se aplicará. Cambia a Live para crear de verdad."
            }
        `,
        confirmText: isLive ? "Sí, crear en sucursal" : "Entendido (no aplica)",
        cancelText: "Cancelar",
    });
    if (!ok) return;

    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';

    try {
        const res = await mutateFetch("/api/comparativa/create-in-expected-branch", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ brand_contact_id: brandContactId, expected_location_id: expectedLocationId }),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(typeof err.detail === "string" ? err.detail : JSON.stringify(err.detail));
        }
        const data = await res.json();
        if (data.dry_run) {
            showToast(`Modo Simulación: se crearía "${name}" en ${expectedBranchName}.`, "info");
            btn.disabled = false;
            btn.innerHTML = originalHTML;
            return;
        }
        showToast(`Contacto "${name}" creado en ${expectedBranchName}. ID: ${data.branch_contact_id}. run_id=${data.run_id || "—"}`, "success");
        btn.innerHTML = '<i class="fa-solid fa-check"></i>';
        btn.classList.add("is-done");
        if (typeof comparativaState !== "undefined") comparativaState.data = null;
        if (typeof loadComparativaMarcaSucursales === "function") {
            loadComparativaMarcaSucursales(true);
        }
    } catch (err) {
        showToast(`Error al crear en sucursal: ${err.message}`, "error");
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}


async function handleUpdateBrandTiendaClick(btn) {
    const brandContactId = btn.dataset.brandContactId;
    const name = btn.dataset.name || "(sin nombre)";
    let branches = [];
    try { branches = JSON.parse(btn.dataset.branches || "[]"); } catch (e) { branches = []; }
    if (!branches.length) { showToast("No hay sucursales destino para usar.", "error"); return; }

    let chosen = branches[0];
    if (branches.length > 1) {
        const idx = await appSelectFromList({
            title: "Elige la sucursal de referencia",
            message: `<strong>${escapeHtmlAttr(name)}</strong> aparece en ${branches.length} sucursales. Selecciona aquella cuya <strong>TIENDA</strong> se aplicará al contacto Marca:`,
            options: branches.map(b => ({
                label: b.name,
                sublabel: `TIENDA: ${b.tienda_value || "—"}`,
            })),
            confirmText: "Usar esta sucursal",
        });
        if (idx === null) return;
        chosen = branches[idx];
    }
    if (!chosen.tienda_value) {
        showToast(`La sucursal ${chosen.name} no tiene un valor TIENDA en el verificador. No se puede actualizar.`, "error");
        return;
    }

    const ok = await appConfirm({
        title: "Cambiar TIENDA del contacto Marca",
        severity: "warning",
        message: `Vas a cambiar el valor <strong>TIENDA</strong> del contacto Marca <strong>${escapeHtmlAttr(name)}</strong> a <strong>${escapeHtmlAttr(chosen.tienda_value)}</strong> (sucursal ${escapeHtmlAttr(chosen.name)}).<br><br>Esto corrige el mapeo del contacto a la sucursal donde realmente está.`,
        confirmText: "Actualizar TIENDA",
    });
    if (!ok) return;

    const originalHTML = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';

    try {
        const res = await mutateFetch("/api/comparativa/update-brand-tienda", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                brand_contact_id: brandContactId,
                new_tienda_value: chosen.tienda_value,
            }),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(err.detail || `HTTP ${res.status}`);
        }
        await res.json();
        btn.innerHTML = '<i class="fa-solid fa-check"></i>';
        btn.title = `TIENDA actualizada a "${chosen.tienda_value}".`;
        btn.classList.add("is-done");
        if (typeof comparativaState !== "undefined") comparativaState.data = null;
    } catch (err) {
        showToast(`Error: ${err.message}`, "error");
        btn.disabled = false;
        btn.innerHTML = originalHTML;
    }
}

async function handleSyncFromBranchClick(syncBtn) {
    const brandContactId = syncBtn.dataset.brandContactId;
    const name = syncBtn.dataset.name || "(sin nombre)";
    let candidates = [];
    try {
        candidates = JSON.parse(syncBtn.dataset.branchCandidates || "[]");
    } catch (e) { candidates = []; }
    if (!candidates.length) {
        showToast("No hay candidatos en sucursal para sincronizar.", "error");
        return;
    }

    let chosen = candidates[0];
    if (candidates.length > 1) {
        const idx = await appSelectFromList({
            title: "Elige el contacto en sucursal",
            message: `Hay ${candidates.length} candidatos en sucursal para <strong>${escapeHtmlAttr(name)}</strong>. Selecciona cuál se usará como fuente:`,
            options: candidates.map(c => ({
                label: c.branch_name,
                sublabel: `${c.opps_count} oportunidad(es)`,
            })),
            confirmText: "Usar este contacto",
        });
        if (idx === null) return;
        chosen = candidates[idx];
    }

    const ok = await appConfirm({
        title: "Sincronizar contacto Marca desde sucursal",
        severity: "warning",
        message: `Vas a actualizar el contacto Marca <strong>${escapeHtmlAttr(name)}</strong> con los datos y custom fields del contacto en <strong>${escapeHtmlAttr(chosen.branch_name)}</strong>.<br><br>Esto pisa los campos del contacto Marca con los de sucursal (los CFs en Marca que no existen en sucursal se conservan).`,
        confirmText: "Sincronizar",
    });
    if (!ok) return;

    const originalHTML = syncBtn.innerHTML;
    syncBtn.disabled = true;
    syncBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';

    try {
        const res = await mutateFetch("/api/comparativa/sync-from-branch-contact", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                brand_contact_id: brandContactId,
                branch_contact_id: chosen.id,
                branch_location_id: chosen.location_id,
            }),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(err.detail || `HTTP ${res.status}`);
        }
        const data = await res.json();
        syncBtn.innerHTML = '<i class="fa-solid fa-check"></i>';
        syncBtn.title = `Sincronizado desde ${chosen.branch_name} (${data.custom_fields_count} custom fields actualizados).`;
        syncBtn.classList.add("is-done");
        if (typeof comparativaState !== "undefined") comparativaState.data = null;
    } catch (err) {
        showToast(`Error: ${err.message}`, "error");
        syncBtn.disabled = false;
        syncBtn.innerHTML = originalHTML;
    }
}

async function handleComparativaBucketAction(e) {
    const syncBtn = e.target.closest(".btn-micro-sync-from-branch");
    if (syncBtn && syncBtn.dataset.action === "sync-from-branch") {
        await handleSyncFromBranchClick(syncBtn);
        return;
    }
    if (syncBtn && syncBtn.dataset.action === "update-branch-from-brand") {
        await handleUpdateBranchFromBrandClick(syncBtn);
        return;
    }
    if (syncBtn && syncBtn.dataset.action === "update-brand-tienda") {
        await handleUpdateBrandTiendaClick(syncBtn);
        return;
    }
    // Botones nuevos del bucket brand_not_in_any_branch.
    const verifyBtn = e.target.closest(".btn-micro-verify");
    if (verifyBtn && verifyBtn.dataset.action === "verify-in-expected-branch") {
        await handleVerifyInExpectedBranchClick(verifyBtn);
        return;
    }
    const createInBranchBtn = e.target.closest(".btn-micro-create-in-branch");
    if (createInBranchBtn && createInBranchBtn.dataset.action === "create-in-expected-branch") {
        await handleCreateInExpectedBranchClick(createInBranchBtn);
        return;
    }
    const fillOppIdBtn = e.target.closest(".btn-micro-fill-opp-id");
    if (fillOppIdBtn && fillOppIdBtn.dataset.action === "fill-opp-id") {
        await handleFillOppIdClick(fillOppIdBtn);
        return;
    }
    const matchBrandOppIdBtn = e.target.closest(".btn-micro-match-brand-opp-id");
    if (matchBrandOppIdBtn && matchBrandOppIdBtn.dataset.action === "match-brand-opp-id") {
        await handleMatchBrandOppIdClick(matchBrandOppIdBtn);
        return;
    }
    const fillContactIdBtn = e.target.closest(".btn-micro-fill-contact-id");
    if (fillContactIdBtn && fillContactIdBtn.dataset.action === "fill-contact-id") {
        await handleFillContactIdClick(fillContactIdBtn);
        return;
    }
    const delBtn = e.target.closest(".btn-micro-delete");
    if (delBtn && delBtn.dataset.action === "delete-brand-contact") {
        const contactId = delBtn.dataset.contactId;
        const name = delBtn.dataset.name || "(sin nombre)";
        const opps = parseInt(delBtn.dataset.opps, 10) || 0;
        const oppsClause = opps > 0
            ? `Tiene <strong>${opps} oportunidad${opps !== 1 ? "es" : ""}</strong> asociada${opps !== 1 ? "s" : ""} que también será${opps !== 1 ? "n" : ""} eliminada${opps !== 1 ? "s" : ""}.<br><br>`
            : "";
        const ok = await appConfirm({
            title: "Eliminar contacto Marca",
            severity: "danger",
            message: `¿Eliminar <strong>${escapeHtmlAttr(name)}</strong> del CRM de Marca?<br><br>${oppsClause}Esta acción es <strong>IRREVERSIBLE</strong>.`,
            confirmText: "Eliminar definitivamente",
        });
        if (!ok) return;

        const originalHTML = delBtn.innerHTML;
        delBtn.disabled = true;
        delBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';

        try {
            const url = `/api/comparativa/contact?contact_id=${encodeURIComponent(contactId)}&location_id=GbKkBpCmKu2QmloKFHy3`;
            const res = await mutateFetch(url, { method: "DELETE" });
            if (!res.ok) {
                const err = await res.json().catch(() => ({ detail: res.statusText }));
                throw new Error(err.detail || `HTTP ${res.status}`);
            }
            const data = await res.json();

            // Remover fila visualmente
            const tr = delBtn.closest("tr");
            const containerBody = tr ? tr.closest(".comparativa-bucket-body") : null;
            const nameNorm = delBtn.dataset.nameNorm || "";
            if (tr) tr.remove();

            // Actualizar badge del bucket al que pertenece la fila eliminada (-1).
            // El bucket se identifica por el id del body (ej. "comp-body-intra_brand_duplicates").
            if (containerBody && containerBody.id) {
                const bucketKey = containerBody.id.replace(/^comp-body-/, "");
                const badge = document.getElementById(`comp-bucket-${bucketKey}`);
                if (badge) {
                    const n = parseInt(badge.textContent.replace(/\D/g, ""), 10) || 0;
                    if (n > 0) badge.textContent = (n - 1).toLocaleString("es-MX");
                }
                // Si es el bucket de duplicados, actualizar contadores del grupo
                // y deshabilitar el delete del ultimo restante.
                if (bucketKey === "intra_brand_duplicates" && nameNorm) {
                    updateIntraBrandDuplicateGroup(containerBody, nameNorm);
                }
            }

            // Invalidar cache del state para que el próximo "Recalcular" pida data fresh.
            if (typeof comparativaState !== "undefined") comparativaState.data = null;
        } catch (err) {
            showToast(`Error al eliminar: ${err.message}`, "error");
            delBtn.disabled = false;
            delBtn.innerHTML = originalHTML;
        }
    }
}

function exportComparativaBucket(uiKey) {
    window.open(`/api/comparativa/marca-vs-sucursales/export?bucket=${encodeURIComponent(uiKey)}`, "_blank");
}

function comparativaSetText(id, value) {
    const el = document.getElementById(id);
    if (el) el.textContent = value;
}

// ==========================================================================
// SYNC TO BRAND (boton + modal generico para distintos buckets)
// ==========================================================================

const SYNC_BUCKETS_CONFIG = {
    "missing_opps_in_brand": {
        triggerBtnId: "btn-sync-missing-opps",
        endpoint: "/api/comparativa/sync-missing-opps",
        modalTitleIcon: "fa-rotate",
        bucketTitle: "opps faltantes en Marca",
        applyNoteHtml: "Escribiendo en GHL. Esto puede demorar (10s por cada contacto nuevo creado).",
        emptyMsg: "No hay oportunidades pendientes de sincronizar a Marca. ¡Todo limpio!",
        summaryFields: (s, isApplyResult) => [
            { label: "Candidatas", value: s.candidates },
            { label: isApplyResult ? "Contactos creados" : "Contactos a crear", value: s.contacts_created },
            { label: isApplyResult ? "Creadas (opps)" : "A crear (opps)", value: s.opps_created },
            { label: isApplyResult ? "Actualizadas (opps)" : "A actualizar (opps)", value: s.opps_updated },
            { label: "Errores", value: s.errors, danger: !!s.errors },
        ],
        columns: ["Acción", "Oportunidad", "Sucursal", "Contacto", "Operaciones"],
        renderRow: (it, isApplyResult) => {
            const statusClass = it.status === "error" ? "is-danger" :
                                it.status === "created" ? "is-create" :
                                it.status === "updated" ? "is-update" : "";
            const statusLabel = {
                "created": isApplyResult ? "Creada" : "Crear",
                "updated": isApplyResult ? "Actualizada" : "Actualizar",
                "error": "Error",
                "pending": "Pendiente",
            }[it.status] || it.status;
            const contactName = (it.branch_contact && it.branch_contact.name) || "(sin contacto)";
            const contactRef = (it.branch_contact && (it.branch_contact.phone || it.branch_contact.email)) || "";
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const actionsHTML = (it.actions || []).map(a => {
                if (a.action === "match_existing_contact") return `<span class="sync-action-pill">match contacto (por ${escapeHtml(a.strategy)})</span>`;
                if (a.action === "create_contact") return `<span class="sync-action-pill is-create">crear contacto</span>`;
                if (a.action === "create_opp") return `<span class="sync-action-pill is-create">crear opp</span>`;
                if (a.action === "update_opp") return `<span class="sync-action-pill is-update">actualizar opp ${escapeHtml(a.brand_opp_id || "")}</span>`;
                return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
            }).join(" ");
            return `
                <tr class="${statusClass}">
                    <td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
                    <td>${escapeHtml(it.opp_name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.opp_id)}</span></td>
                    <td>${escapeHtml(it.branch_name)}</td>
                    <td>${escapeHtml(contactName)}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(contactRef)}</span></td>
                    <td>${actionsHTML}${errorHTML}</td>
                </tr>
            `;
        },
    },
    "missing_in_brand": {
        triggerBtnId: "btn-sync-missing-contacts",
        endpoint: "/api/comparativa/sync-missing-contacts",
        modalTitleIcon: "fa-user-plus",
        bucketTitle: "contactos faltantes en Marca",
        applyNoteHtml: "Creando contactos en Marca. Cada uno se verifica por teléfono → email → nombre antes de crear.",
        emptyMsg: "No hay contactos pendientes de sincronizar a Marca. ¡Todo limpio!",
        summaryFields: (s, isApplyResult) => [
            { label: "Candidatos", value: s.candidates },
            { label: isApplyResult ? "Creados" : "A crear", value: s.contacts_created },
            { label: "Ya existían en Marca", value: s.skipped_already_in_brand || 0 },
            { label: "Errores", value: s.errors, danger: !!s.errors },
        ],
        columns: ["Acción", "Contacto", "Sucursal", "Datos", "Operaciones"],
        renderRow: (it, isApplyResult) => {
            const statusClass = it.status === "error" ? "is-danger" :
                                it.status === "created" ? "is-create" :
                                it.status === "skipped" ? "is-skip" : "";
            const statusLabel = {
                "created": isApplyResult ? "Creado" : "Crear",
                "skipped": "Ya existe",
                "error": "Error",
                "pending": "Pendiente",
            }[it.status] || it.status;
            const dataParts = [it.phone, it.email].filter(Boolean).join(" / ");
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const actionsHTML = (it.actions || []).map(a => {
                if (a.action === "skip_already_in_brand") return `<span class="sync-action-pill">ya existe (por ${escapeHtml(a.strategy)})</span>`;
                if (a.action === "create_contact") return `<span class="sync-action-pill is-create">crear contacto</span>`;
                return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
            }).join(" ");
            return `
                <tr class="${statusClass}">
                    <td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
                    <td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.branch_contact_id || "")}</span></td>
                    <td>${escapeHtml(it.branch_name)}</td>
                    <td><span style="font-size: 12px;">${escapeHtml(dataParts || "—")}</span>${it.opps_in_branch ? `<br><span class="text-muted" style="font-size: 11px;">${it.opps_in_branch} opps locales</span>` : ""}</td>
                    <td>${actionsHTML}${errorHTML}</td>
                </tr>
            `;
        },
    },
    "brand_without_tienda": {
        triggerBtnId: "btn-fix-tienda-from-sucursal",
        endpoint: "/api/comparativa/fix-tienda-from-sucursal",
        modalTitleIcon: "fa-wand-magic-sparkles",
        bucketTitle: "rellenar TIENDA desde Sucursal",
        applyNoteHtml: "Escribiendo el campo TIENDA en Marca a partir del valor de Sucursal resuelto via verificador.",
        emptyMsg: "No hay contactos con Sucursal resoluble pendientes. Los demás necesitan revisión manual.",
        summaryFields: (s, isApplyResult) => [
            { label: "Candidatos", value: s.candidates },
            { label: isApplyResult ? "Actualizados" : "A actualizar", value: s.contacts_updated },
            { label: "Skip · sin Sucursal", value: s.skipped_no_sucursal || 0 },
            { label: "Skip · Sucursal sin match", value: s.skipped_unresolved_sucursal || 0 },
            { label: "Skip · TIENDA ya poblada", value: s.skipped_already_has_tienda || 0 },
            { label: "Errores", value: s.errors, danger: !!s.errors },
        ],
        columns: ["Acción", "Contacto Marca", "Sucursal (CF)", "Sucursal esperada", "TIENDA a escribir", "Operaciones"],
        renderRow: (it, isApplyResult) => {
            const statusClass = it.status === "error" ? "is-danger" :
                                it.status === "updated" ? "is-update" :
                                (it.status || "").startsWith("skipped") ? "is-skip" : "";
            const statusLabel = {
                "updated": isApplyResult ? "Actualizado" : "Actualizar",
                "skipped_no_sucursal": "Sin Sucursal",
                "skipped_unresolved_sucursal": "Sucursal sin match",
                "skipped_already_has_tienda": "Ya tenía TIENDA",
                "error": "Error",
                "pending": "Pendiente",
            }[it.status] || it.status;
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const actionsHTML = (it.actions || []).map(a => {
                if (a.action === "skip_no_sucursal") return `<span class="sync-action-pill">sin Sucursal</span>`;
                if (a.action === "skip_unresolved_sucursal") return `<span class="sync-action-pill">Sucursal '${escapeHtml(a.sucursal || "")}' no mapea</span>`;
                if (a.action === "skip_already_has_tienda") return `<span class="sync-action-pill">ya tenía TIENDA='${escapeHtml(a.current_tienda || "")}'</span>`;
                if (a.action === "update_tienda") {
                    const kind = a.resolution_kind === "substring" ? " (substring)" : "";
                    return `<span class="sync-action-pill is-update">escribir TIENDA${kind}</span>`;
                }
                return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
            }).join(" ");
            return `
                <tr class="${statusClass}">
                    <td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
                    <td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.brand_contact_id || "")}</span></td>
                    <td>${escapeHtml(it.sucursal || "—")}</td>
                    <td>${escapeHtml(it.expected_branch_name || "—")}</td>
                    <td>${escapeHtml(it.expected_tienda || "—")}</td>
                    <td>${actionsHTML}${errorHTML}</td>
                </tr>
            `;
        },
    },
    "brand_not_in_any_branch": {
        triggerBtnId: "btn-sync-brand-to-branch",
        endpoint: "/api/comparativa/sync-brand-to-branch-contacts",
        modalTitleIcon: "fa-arrow-right-arrow-left",
        bucketTitle: "contactos de Marca → Sucursal",
        applyNoteHtml: "Creando contactos en sus sucursales correspondientes. Antes de crear se verifica en la sucursal destino Y globalmente en todas las demás. Los creates corren en paralelo.",
        emptyMsg: "No hay contactos pendientes de propagar a sucursales. ¡Todo limpio!",
        summaryFields: (s, isApplyResult) => [
            { label: "Candidatos", value: s.candidates },
            { label: isApplyResult ? "Creados en sucursal" : "A crear en sucursal", value: s.contacts_created },
            { label: "Ya existían en destino", value: s.skipped_already_in_branch || 0 },
            { label: "Ya existían en OTRA sucursal", value: s.skipped_in_other_branch || 0 },
            { label: "Skip · sin TIENDA", value: s.skipped_no_tienda || 0 },
            { label: "Skip · TIENDA desconocida", value: s.skipped_unknown_tienda || 0 },
            { label: "Errores", value: s.errors, danger: !!s.errors },
        ],
        columns: ["Acción", "Contacto Marca", "TIENDA", "Sucursal destino", "Datos", "Operaciones"],
        renderRow: (it, isApplyResult) => {
            const statusClass = it.status === "error" ? "is-danger" :
                                it.status === "created" ? "is-create" :
                                (it.status || "").startsWith("skipped") ? "is-skip" : "";
            const statusLabel = {
                "created": isApplyResult ? "Creado" : "Crear",
                "skipped_already_in_branch": "Ya en destino",
                "skipped_in_other_branch": "En otra sucursal",
                "skipped_no_tienda": "Sin TIENDA",
                "skipped_unknown_tienda": "TIENDA desconocida",
                "error": "Error",
                "pending": "Pendiente",
            }[it.status] || it.status;
            const dataParts = [it.phone, it.email].filter(Boolean).join(" / ");
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const actionsHTML = (it.actions || []).map(a => {
                if (a.action === "skip_no_tienda") return `<span class="sync-action-pill">sin TIENDA</span>`;
                if (a.action === "skip_unknown_tienda") return `<span class="sync-action-pill">TIENDA '${escapeHtml(a.tienda || "")}' desconocida</span>`;
                if (a.action === "skip_already_in_branch") return `<span class="sync-action-pill">ya existe en destino (por ${escapeHtml(a.strategy)})</span>`;
                if (a.action === "skip_in_other_branch") return `<span class="sync-action-pill" title="${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')}">ya en otra: ${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')} (por ${escapeHtml(a.strategy)})</span>`;
                if (a.action === "create_contact_in_branch") return `<span class="sync-action-pill is-create">crear en sucursal</span>`;
                return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
            }).join(" ");
            return `
                <tr class="${statusClass}">
                    <td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
                    <td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.brand_contact_id || "")}</span></td>
                    <td>${escapeHtml(it.tienda || "—")}</td>
                    <td>${escapeHtml(it.target_branch_name || "—")}</td>
                    <td><span style="font-size: 12px;">${escapeHtml(dataParts || "—")}</span></td>
                    <td>${actionsHTML}${errorHTML}</td>
                </tr>
            `;
        },
    },
    // Mismo endpoint y mismo render que brand_not_in_any_branch (los items son
    // un subset: contactos con TIENDA resuelta y ausentes globalmente). La
    // diferencia es que aqui SIEMPRE se abre con un subset de contact_ids
    // seleccionados por el usuario en la tabla del bucket.
    "missing_in_assigned_branch": {
        triggerBtnId: "btn-sync-missing-in-assigned-branch",
        endpoint: "/api/comparativa/sync-brand-to-branch-contacts",
        modalTitleIcon: "fa-user-plus",
        bucketTitle: "crear seleccionados en sucursal asignada",
        applyNoteHtml: "Creando los contactos seleccionados en su sucursal asignada. Antes de crear se verifica en la sucursal destino Y globalmente en todas las demás.",
        emptyMsg: "No hay contactos seleccionados. Marca los contactos en el listado primero.",
        summaryFields: (s, isApplyResult) => [
            { label: "Candidatos", value: s.candidates },
            { label: isApplyResult ? "Creados en sucursal" : "A crear en sucursal", value: s.contacts_created },
            { label: "Ya existían en destino", value: s.skipped_already_in_branch || 0 },
            { label: "Ya existían en OTRA sucursal", value: s.skipped_in_other_branch || 0 },
            { label: "Skip · sin TIENDA", value: s.skipped_no_tienda || 0 },
            { label: "Skip · TIENDA desconocida", value: s.skipped_unknown_tienda || 0 },
            { label: "Errores", value: s.errors, danger: !!s.errors },
        ],
        columns: ["Acción", "Contacto Marca", "TIENDA", "Sucursal destino", "Datos", "Operaciones"],
        renderRow: (it, isApplyResult) => {
            const statusClass = it.status === "error" ? "is-danger" :
                                it.status === "created" ? "is-create" :
                                (it.status || "").startsWith("skipped") ? "is-skip" : "";
            const statusLabel = {
                "created": isApplyResult ? "Creado" : "Crear",
                "skipped_already_in_branch": "Ya en destino",
                "skipped_in_other_branch": "En otra sucursal",
                "skipped_no_tienda": "Sin TIENDA",
                "skipped_unknown_tienda": "TIENDA desconocida",
                "error": "Error",
                "pending": "Pendiente",
            }[it.status] || it.status;
            const dataParts = [it.phone, it.email].filter(Boolean).join(" / ");
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const actionsHTML = (it.actions || []).map(a => {
                if (a.action === "skip_no_tienda") return `<span class="sync-action-pill">sin TIENDA</span>`;
                if (a.action === "skip_unknown_tienda") return `<span class="sync-action-pill">TIENDA '${escapeHtml(a.tienda || "")}' desconocida</span>`;
                if (a.action === "skip_already_in_branch") return `<span class="sync-action-pill">ya existe en destino (por ${escapeHtml(a.strategy)})</span>`;
                if (a.action === "skip_in_other_branch") return `<span class="sync-action-pill" title="${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')}">ya en otra: ${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')} (por ${escapeHtml(a.strategy)})</span>`;
                if (a.action === "create_contact_in_branch") return `<span class="sync-action-pill is-create">crear en sucursal</span>`;
                return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
            }).join(" ");
            return `
                <tr class="${statusClass}">
                    <td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
                    <td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.brand_contact_id || "")}</span></td>
                    <td>${escapeHtml(it.tienda || "—")}</td>
                    <td>${escapeHtml(it.target_branch_name || "—")}</td>
                    <td><span style="font-size: 12px;">${escapeHtml(dataParts || "—")}</span></td>
                    <td>${actionsHTML}${errorHTML}</td>
                </tr>
            `;
        },
    },
    "contacts_missing_id_field": {
        triggerBtnId: "btn-fill-contact-id-sucursal",
        endpoint: "/api/comparativa/fill-contact-id-sucursal",
        // bodyIdsField default "contact_ids" — coincide con el endpoint
        modalTitleIcon: "fa-wand-magic-sparkles",
        bucketTitle: "llenar ID Contacto Sucursal en sucursales",
        applyNoteHtml: "Escribiendo el campo en GHL. Cada contacto queda con su propio id nativo (idempotente). No toca Marca.",
        emptyMsg: "No hay contactos de sucursal con el campo vacío o longitud inválida. ¡Todo en orden!",
        summaryFields: (s, isApplyResult) => [
            { label: "Cuentas en scope", value: s.accounts_processed || 0 },
            { label: "Contactos revisados", value: s.contacts_reviewed || 0 },
            { label: isApplyResult ? "Campos llenados" : "A llenar", value: s.set || 0 },
            { label: "Ya OK (idempotente)", value: s.skipped || 0 },
            { label: "Errores", value: s.errors || 0, danger: !!s.errors },
        ],
        columns: ["Cuenta", "Contactos revisados", "Llenados", "Ya OK", "Errores"],
        renderRow: (it, isApplyResult) => {
            const statusClass = (it.status === "apply_error" || it.status === "plan_error" || (it.errors || 0) > 0)
                ? "is-danger"
                : ((it.set || 0) > 0 ? "is-update" : "is-skip");
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const accountCell = `${escapeHtml(it.account || it.location_id || "")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.location_id || "")}</span>`;
            return `
                <tr class="${statusClass}">
                    <td>${accountCell}</td>
                    <td>${escapeHtml(String(it.contacts_in_scope || 0))}</td>
                    <td><span class="sync-status-pill ${(it.set || 0) > 0 ? "is-update" : ""}">${escapeHtml(String(it.set || 0))}</span></td>
                    <td>${escapeHtml(String(it.skipped || 0))}</td>
                    <td>${escapeHtml(String(it.errors || 0))}${errorHTML}</td>
                </tr>
            `;
        },
    },
    "opps_missing_id_field": {
        triggerBtnId: "btn-fill-opp-id-sucursal",
        endpoint: "/api/comparativa/fill-opp-id-sucursal",
        bodyIdsField: "opp_ids",
        modalTitleIcon: "fa-wand-magic-sparkles",
        bucketTitle: "llenar ID Oportunidad Sucursal en sucursales",
        applyNoteHtml: "Escribiendo el campo en GHL. Cada opp queda con su propio id nativo (idempotente). No toca Marca.",
        emptyMsg: "No hay opps de sucursal con el campo vacío o longitud inválida. ¡Todo en orden!",
        summaryFields: (s, isApplyResult) => [
            { label: "Cuentas en scope", value: s.accounts_processed || 0 },
            { label: "Opps revisadas", value: s.opps_reviewed || 0 },
            { label: isApplyResult ? "Campos llenados" : "A llenar", value: s.set || 0 },
            { label: "Ya OK (idempotente)", value: s.skipped || 0 },
            { label: "Errores", value: s.errors || 0, danger: !!s.errors },
        ],
        columns: ["Cuenta", "Opps revisadas", "Llenados", "Ya OK", "Errores"],
        renderRow: (it, isApplyResult) => {
            const statusClass = (it.status === "apply_error" || it.status === "plan_error" || (it.errors || 0) > 0)
                ? "is-danger"
                : ((it.set || 0) > 0 ? "is-update" : "is-skip");
            const errorHTML = it.error ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
            const accountCell = `${escapeHtml(it.account || it.location_id || "")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.location_id || "")}</span>`;
            return `
                <tr class="${statusClass}">
                    <td>${accountCell}</td>
                    <td>${escapeHtml(String(it.opps_in_scope || 0))}</td>
                    <td><span class="sync-status-pill ${(it.set || 0) > 0 ? "is-update" : ""}">${escapeHtml(String(it.set || 0))}</span></td>
                    <td>${escapeHtml(String(it.skipped || 0))}</td>
                    <td>${escapeHtml(String(it.errors || 0))}${errorHTML}</td>
                </tr>
            `;
        },
    },
    "opps_missing_id_field_brand": {
        triggerBtnId: "btn-match-brand-opp-id-sucursal",
        endpoint: "/api/comparativa/match-brand-opp-id-sucursal",
        bodyIdsField: "opp_ids",
        modalTitleIcon: "fa-arrows-left-right-to-line",
        bucketTitle: "matchear opps de Marca con sucursales",
        applyNoteHtml: "Escribiendo el campo 'ID Oportunidad Sucursal' en Marca con el id de la opp sucursal correspondiente. Solo se aplican los matches inequívocos (1-a-1). No toca sucursales.",
        emptyMsg: "No hay matches inequívocos para Marca. Las opps en review/no-match requieren inspección manual.",
        hasWorkToDo: (s) => (s.match_unique || 0) > 0,
        summaryFields: (s, isApplyResult) => [
            { label: "Opps Marca evaluadas", value: s.plans_total || 0 },
            { label: isApplyResult ? "Matches aplicados" : "Match único (a aplicar)", value: isApplyResult ? (s.applied || 0) : (s.match_unique || 0) },
            { label: "Revisión (ambiguos / count)", value: (s.review_ambiguous || 0) + (s.review_count_mismatch || 0) },
            { label: "Sin opps en sucursal", value: s.branch_contact_no_opps || 0 },
            { label: "Sin contacto sucursal", value: (s.no_branch_contact || 0) + (s.phone_collision || 0) + (s.no_data || 0) + (s.no_brand_contact || 0) },
            { label: "Errores", value: s.errors || 0, danger: !!s.errors },
        ],
        columns: ["Opp Marca", "Estado", "Motivo / branch opp", "Sucursal"],
        renderRow: (it, isApplyResult) => {
            const plan = it.plan || {};
            const st = plan.status || "—";
            const isMatch = st === "match_unique";
            const cls = isMatch ? "is-update" : (st.startsWith("review_") ? "is-skip" : "is-danger");
            const oppName = it.marca_opp_name || "(sin nombre)";
            const detail = isMatch
                ? `<code title="branch opp id">${escapeHtml(plan.branch_opp_id || "")}</code> <span class="text-muted">(${escapeHtml(plan.matched_by || "")})</span>`
                : (st === "review_count_mismatch" || st === "review_ambiguous")
                    ? `M=${plan.M ?? "?"} / N=${plan.N ?? "?"}`
                    : `<span class="text-muted">${escapeHtml(st)}</span>`;
            const loc = plan.branch_location ? `<code style="font-size:11px;">${escapeHtml(plan.branch_location)}</code>` : "—";
            return `
                <tr class="${cls}">
                    <td><code style="font-size:11px;">${escapeHtml(it.marca_opp_id || "")}</code><br>${escapeHtml(oppName)}</td>
                    <td><span class="sync-status-pill ${isMatch ? "is-update" : ""}">${escapeHtml(st)}</span></td>
                    <td>${detail}</td>
                    <td>${loc}</td>
                </tr>
            `;
        },
    },
    "brand_duplicate_link_opps": {
        triggerBtnId: "btn-cleanup-duplicate-opps",
        endpoint: "/api/comparativa/cleanup-duplicate-opps",
        modalTitleIcon: "fa-broom",
        bucketTitle: "réplicas duplicadas en Marca",
        applyNoteHtml: "Eliminando en GHL las réplicas sobrantes (conserva la canónica). El DELETE no es reversible en GHL, pero queda snapshot + run_id para auditoría/recreación.",
        emptyMsg: "No hay réplicas duplicadas en Marca. ¡Todo limpio!",
        hasWorkToDo: (s) => (s.extra_opps || 0) > 0,
        summaryFields: (s, isApplyResult) => [
            { label: "Clusters (mismo ID Opp Sucursal)", value: s.clusters || 0 },
            { label: isApplyResult ? "Réplicas borradas" : "Réplicas a borrar", value: isApplyResult ? (s.deleted || 0) : (s.extra_opps || 0) },
            { label: "Clusters resueltos en vivo", value: s.ok || 0 },
            { label: "Omitidos (ya sin duplicado)", value: s.skipped || 0 },
            { label: "Errores", value: s.errors || 0, danger: !!s.errors },
        ],
        columns: ["Acción", "Oportunidad", "Conservar", "Borrar", "ID Opp Sucursal"],
        renderRow: (it, isApplyResult) => {
            const k = it.keep || {};
            const dels = it.delete || [];
            const statusClass = isApplyResult ? "is-danger" : "is-skip";
            const statusLabel = isApplyResult ? "Borrada" : "Borrar";
            const keepHTML = `<code style="font-size:11px;">${escapeHtml(k.id || "")}</code><br><span class="text-muted" style="font-size:11px;">${escapeHtml(k.status || "")} · $${Number(k.monetaryValue || 0).toLocaleString("es-MX")} · ${escapeHtml((k.createdAt || "").slice(0, 10))}</span>`;
            const delHTML = dels.map(d => `<code style="font-size:11px;">${escapeHtml(d.id || "")}</code> <span class="text-muted" style="font-size:11px;">(${escapeHtml((d.createdAt || "").slice(0, 10))})</span>`).join("<br>");
            return `
                <tr class="${statusClass}">
                    <td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
                    <td>${escapeHtml(it.name || "(sin nombre)")}</td>
                    <td>${keepHTML}</td>
                    <td>${delHTML}</td>
                    <td><code style="font-size:11px;">${escapeHtml(it.link_value || "")}</code></td>
                </tr>
            `;
        },
    },
};

let syncBucketState = {
    bound: false,
    activeBucket: null,
    planResult: null,
    loading: false,
    contactIds: null,  // null = todos los del bucket; array = subset seleccionado por el usuario.
};

function bindSyncBucketEvents() {
    if (syncBucketState.bound) return;
    Object.entries(SYNC_BUCKETS_CONFIG).forEach(([bucketKey, cfg]) => {
        const btn = document.getElementById(cfg.triggerBtnId);
        if (!btn) return;
        btn.addEventListener("click", () => {
            // Buckets con seleccion en bulk: pasamos los IDs marcados.
            if (bucketKey === "missing_in_assigned_branch") {
                const sel = (typeof getBulkSelectionSet === "function")
                    ? getBulkSelectionSet("missing_in_assigned_branch")
                    : new Set();
                if (!sel.size) {
                    if (typeof showToast === "function") {
                        showToast("Selecciona al menos un contacto del listado para crear en sucursal.", "info");
                    }
                    return;
                }
                openSyncBucketModal(bucketKey, { contactIds: Array.from(sel) });
                return;
            }
            // contacts_missing_id_field: el llenado SOLO aplica a sucursales
            // (Marca se resuelve por matcheo/sync). Filtramos los items.
            if (bucketKey === "contacts_missing_id_field") {
                const items = (comparativaState && comparativaState.data
                    && comparativaState.data.missing
                    && comparativaState.data.missing.contacts_missing_id_field
                    && comparativaState.data.missing.contacts_missing_id_field.items) || [];
                const branchContactIds = items.filter(it => !it.is_brand).map(it => it.id).filter(Boolean);
                if (!branchContactIds.length) {
                    if (typeof showToast === "function") {
                        showToast("No hay contactos de sucursal con el campo vacío o inválido.", "info");
                    }
                    return;
                }
                openSyncBucketModal(bucketKey, { contactIds: branchContactIds });
                return;
            }
            // opps_missing_id_field: el llenado SOLO aplica a sucursales (Marca
            // se resuelve por matcheo/sync). Filtramos del bucket actual.
            if (bucketKey === "opps_missing_id_field") {
                const items = (comparativaState && comparativaState.data
                    && comparativaState.data.missing
                    && comparativaState.data.missing.opportunities_missing_id_field
                    && comparativaState.data.missing.opportunities_missing_id_field.items) || [];
                const branchOppIds = items.filter(it => !it.is_brand).map(it => it.id).filter(Boolean);
                if (!branchOppIds.length) {
                    if (typeof showToast === "function") {
                        showToast("No hay opps de sucursal con el campo vacío o inválido. (Las de Marca se resuelven por matcheo/sync.)", "info");
                    }
                    return;
                }
                openSyncBucketModal(bucketKey, { contactIds: branchOppIds });
                return;
            }
            // opps_missing_id_field_brand: bucket sintético que apunta al endpoint
            // de matching Marca↔sucursal. Filtra las filas Marca del bucket original.
            if (bucketKey === "opps_missing_id_field_brand") {
                const items = (comparativaState && comparativaState.data
                    && comparativaState.data.missing
                    && comparativaState.data.missing.opportunities_missing_id_field
                    && comparativaState.data.missing.opportunities_missing_id_field.items) || [];
                const brandOppIds = items.filter(it => it.is_brand).map(it => it.id).filter(Boolean);
                if (!brandOppIds.length) {
                    if (typeof showToast === "function") {
                        showToast("No hay opps de Marca con el campo vacío o inválido para matchear.", "info");
                    }
                    return;
                }
                openSyncBucketModal(bucketKey, { contactIds: brandOppIds });
                return;
            }
            openSyncBucketModal(bucketKey);
        });
    });
    const btnClose = document.getElementById("sync-modal-close");
    const btnCancel = document.getElementById("sync-modal-cancel");
    const btnApply = document.getElementById("sync-modal-apply");
    const overlay = document.getElementById("sync-missing-opps-modal");
    if (btnClose) btnClose.addEventListener("click", closeSyncBucketModal);
    if (btnCancel) btnCancel.addEventListener("click", closeSyncBucketModal);
    if (btnApply) btnApply.addEventListener("click", applySyncBucket);
    if (overlay) {
        overlay.addEventListener("click", (e) => {
            if (e.target === overlay) closeSyncBucketModal();
        });
    }
    syncBucketState.bound = true;
}

function openSyncBucketModal(bucketKey, opts) {
    bindSyncBucketEvents();
    syncBucketState.activeBucket = bucketKey;
    syncBucketState.contactIds = (opts && Array.isArray(opts.contactIds) && opts.contactIds.length)
        ? opts.contactIds.slice()
        : null;
    const modal = document.getElementById("sync-missing-opps-modal");
    if (!modal) return;
    modal.hidden = false;
    modal.classList.add("is-open");
    runSyncBucketDryRun();
}

function closeSyncBucketModal() {
    const modal = document.getElementById("sync-missing-opps-modal");
    if (!modal) return;
    modal.hidden = true;
    modal.classList.remove("is-open");
    syncBucketState.planResult = null;
    syncBucketState.activeBucket = null;
    syncBucketState.contactIds = null;
    const apply = document.getElementById("sync-modal-apply");
    if (apply) apply.hidden = true;
    // Restaurar texto del boton por si la proxima apertura arranca en modo plan.
    const cancel = document.getElementById("sync-modal-cancel");
    if (cancel) cancel.innerHTML = "Cancelar";
}

async function runSyncBucketDryRun() {
    const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket];
    if (!cfg) return;
    const body = document.getElementById("sync-modal-body");
    const apply = document.getElementById("sync-modal-apply");
    if (body) body.innerHTML = `<p class="text-secondary"><i class="fa-solid fa-spinner fa-spin"></i> Calculando plan... esto consulta el audit completo, puede tomar unos segundos.</p>`;
    if (apply) apply.hidden = true;
    syncBucketState.loading = true;

    try {
        // Preview/plan SIEMPRE es dry_run=true (sin afectar al toggle global).
        const bodyPayload = { dry_run: true };
        const idsField = cfg.bodyIdsField || "contact_ids";
        if (Array.isArray(syncBucketState.contactIds) && syncBucketState.contactIds.length) {
            bodyPayload[idsField] = syncBucketState.contactIds;
        }
        const res = await mutateFetch(cfg.endpoint, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(bodyPayload),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(err.detail || `HTTP ${res.status}`);
        }
        const data = await res.json();
        syncBucketState.planResult = data;
        renderSyncBucketPlan(data, /*isApplyResult*/ false);
        // Solo mostramos Aplicar si hay candidatos Y no son todos skips (skips no requieren apply).
        // El bucket puede definir su propia función hasWorkToDo(summary); si no, usa el default.
        const summary = data.summary || {};
        const hasWorkToDo = typeof cfg.hasWorkToDo === "function"
            ? cfg.hasWorkToDo(summary)
            : (summary.candidates > 0 &&
               ((summary.contacts_created || 0) > 0 ||
                (summary.contacts_updated || 0) > 0 ||
                (summary.opps_created || 0) > 0 ||
                (summary.opps_updated || 0) > 0));
        if (apply && hasWorkToDo) apply.hidden = false;
    } catch (e) {
        if (body) body.innerHTML = `<p class="text-danger"><i class="fa-solid fa-circle-exclamation"></i> Error: ${escapeHtml(e.message)}</p>`;
    } finally {
        syncBucketState.loading = false;
    }
}

async function applySyncBucket() {
    const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket];
    if (!cfg || syncBucketState.loading) return;
    const apply = document.getElementById("sync-modal-apply");
    const cancel = document.getElementById("sync-modal-cancel");
    const body = document.getElementById("sync-modal-body");
    if (apply) {
        apply.disabled = true;
        apply.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> Aplicando en GHL...`;
    }
    if (cancel) cancel.disabled = true;
    if (body) {
        const note = document.createElement("p");
        note.className = "text-secondary";
        note.style.marginTop = "12px";
        note.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${cfg.applyNoteHtml}`;
        body.prepend(note);
    }
    syncBucketState.loading = true;

    try {
        // El "aplicar" respeta el modo global: en dry-run, fuerza dry_run=true
        // en el body para que el endpoint legacy no escriba en Bucéfalo. El
        // header X-Dry-Run también va, por si el endpoint usa el helper nuevo.
        const respectGlobalMode = window.AppMode.isDryRun;
        const bodyPayload = { dry_run: respectGlobalMode, yes: true };
        const idsField = cfg.bodyIdsField || "contact_ids";
        if (Array.isArray(syncBucketState.contactIds) && syncBucketState.contactIds.length) {
            bodyPayload[idsField] = syncBucketState.contactIds;
        }
        const res = await mutateFetch(cfg.endpoint, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(bodyPayload),
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ detail: res.statusText }));
            throw new Error(err.detail || `HTTP ${res.status}`);
        }
        const data = await res.json();
        renderSyncBucketPlan(data, /*isApplyResult*/ true);
        if (apply) apply.hidden = true;
        // Tras un apply exitoso de un bucket con seleccion en bulk, limpia el
        // set guardado en memoria para que el siguiente render del bucket
        // arranque desde cero (los IDs procesados ya no son candidatos).
        const bucketKey = syncBucketState.activeBucket;
        if (bucketKey === "missing_in_assigned_branch" && typeof clearBulkSelection === "function") {
            clearBulkSelection(bucketKey);
        }
        comparativaState.data = null;
        loadComparativaMarcaSucursales(true);
    } catch (e) {
        if (body) {
            const errEl = document.createElement("p");
            errEl.className = "text-danger";
            errEl.innerHTML = `<i class="fa-solid fa-circle-exclamation"></i> Error: ${escapeHtml(e.message)}`;
            body.prepend(errEl);
        }
    } finally {
        syncBucketState.loading = false;
        if (apply) {
            apply.disabled = false;
            apply.innerHTML = `<i class="fa-solid fa-check"></i> Aplicar cambios en GHL`;
        }
        if (cancel) cancel.disabled = false;
    }
}

function renderSyncBucketPlan(data, isApplyResult) {
    const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket];
    if (!cfg) return;
    const body = document.getElementById("sync-modal-body");
    const title = document.getElementById("sync-modal-title");
    if (!body) return;

    const s = data.summary || {};
    const items = data.items || [];

    if (title) {
        title.innerHTML = isApplyResult
            ? `<i class="fa-solid fa-check-double" style="color: var(--color-success);"></i> Resultado de la sincronización · ${cfg.bucketTitle}`
            : `<i class="fa-solid ${cfg.modalTitleIcon}"></i> Plan de sincronización · ${cfg.bucketTitle}`;
    }

    // Tras aplicar los cambios ya no tiene sentido "Cancelar" (los cambios
    // estan en GHL y el rollback se hace por run_id desde Scripts y Auditorias).
    // Cambiamos el texto a "Cerrar" para que el boton refleje lo que realmente hace.
    const cancelBtn = document.getElementById("sync-modal-cancel");
    if (cancelBtn) {
        cancelBtn.innerHTML = isApplyResult
            ? `<i class="fa-solid fa-xmark"></i> Cerrar`
            : `Cancelar`;
    }

    if (!s.candidates) {
        body.innerHTML = `<p class="text-secondary">${cfg.emptyMsg}</p>`;
        return;
    }

    const fields = cfg.summaryFields(s, isApplyResult);
    const summaryHTML = `
        <div class="sync-summary-grid">
            ${fields.map(f => `<div class="sync-summary-cell ${f.danger ? "is-danger" : ""}"><span>${escapeHtml(f.label)}</span><strong>${f.value}</strong></div>`).join("")}
        </div>
        ${data.run_id ? `<p class="text-muted" style="font-size: 11px; margin: 8px 0 0;">run_id: <code>${escapeHtml(data.run_id)}</code> (disponible para rollback desde Scripts y Auditorías)</p>` : ""}
        ${isApplyResult ? "" : "<p class='text-secondary' style='margin: 12px 0;'>Revisa el detalle a continuación. Si todo OK, haz click en <strong>Aplicar cambios en GHL</strong>.</p>"}
    `;

    const detailRows = items.map(it => cfg.renderRow(it, isApplyResult)).join("");

    body.innerHTML = `
        ${summaryHTML}
        <div class="sync-detail-wrapper">
            <table class="data-table sync-detail-table">
                <thead>
                    <tr>${cfg.columns.map(c => `<th>${escapeHtml(c)}</th>`).join("")}</tr>
                </thead>
                <tbody>${detailRows}</tbody>
            </table>
        </div>
    `;
}

document.addEventListener("DOMContentLoaded", () => {
    bindSyncBucketEvents();
});

