'use strict';

const { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell } = require('electron');
const path   = require('path');
const fs     = require('fs');
const os     = require('os');
const crypto = require('crypto');
const { spawn, execSync } = require('child_process');
const { downloadRelease } = require('./updateDownloader');

// ── AppUserModelId (Windows taskbar) ─────────────────────────────────────────
// DEBE llamarse antes de app.whenReady() y antes de que se cree cualquier ventana.
// Sin esto, Windows agrupa el proceso bajo "electron.exe" y usa el ícono de Electron
// en la barra de tareas en vez del ícono de la app.
if (process.platform === 'win32') {
    app.setAppUserModelId('com.xponencia.worker');
}

// ── Single instance ───────────────────────────────────────────────────────────
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
    // Esta rama corría en silencio total: sin consola, sin reportWorkerEvent (depende
    // de SCRIPT_DIR/red, que ni se inicializaron todavía) — un afiliado con un lock
    // huérfano (instancia previa colgada) veía "no arranca" sin ningún rastro en
    // ningún lado. Dejamos al menos un archivo local, sin depender de nada más.
    try {
        fs.appendFileSync(
            path.join(os.tmpdir(), 'xponencia-worker-lock.log'),
            `[${new Date().toISOString()}] Segunda instancia — lock ya tomado por otro proceso. Saliendo. PID=${process.pid}\n`
        );
    } catch (_) {}
    app.quit();
}

let isQuitting    = false;
let mainWindow    = null;
let tray          = null;
let workerProcess = null;
let popupWindow   = null;

// ── Power save blocker ────────────────────────────────────────────────────────
// Previene que Windows suspenda / hiberne la PC mientras el worker está corriendo.
// Se activa cuando el worker process arranca y se desactiva cuando para.
// 'prevent-app-suspension': bloquea sleep/hibernación pero permite que la
//   pantalla se apague (ahorro de energía sin impactar el worker).
// No se activa si el worker está detenido — la PC puede dormir normalmente.
let _powerBlockerId = null;

function _startPowerBlock() {
    if (_powerBlockerId !== null) return; // ya activo
    try {
        const { powerSaveBlocker } = require('electron');
        _powerBlockerId = powerSaveBlocker.start('prevent-app-suspension');
        // Confirmar que el SO aceptó el bloqueo (diagnóstico): si isStarted=false,
        // el bloqueo no tomó efecto y la PC puede dormir igual.
        const ok = powerSaveBlocker.isStarted(_powerBlockerId);
        console.log(`[POWER] powerSaveBlocker iniciado id=${_powerBlockerId} activo=${ok}`);
    } catch (_) {}
}

function _stopPowerBlock() {
    if (_powerBlockerId === null) return;
    try {
        const { powerSaveBlocker } = require('electron');
        if (powerSaveBlocker.isStarted(_powerBlockerId)) {
            powerSaveBlocker.stop(_powerBlockerId);
        }
    } catch (_) {}
    _powerBlockerId = null;
}

// ── Licencias ─────────────────────────────────────────────────────────────────
let activationWindow = null;
let licensePoller    = null;
let _cachedHwid      = null;

function getWorkerHwid() {
    if (_cachedHwid) return _cachedHwid;
    try {
        const { machineIdSync } = require('node-machine-id');
        _cachedHwid = machineIdSync();
        return _cachedHwid;
    } catch (_) {}
    try {
        const out = execSync(
            'REG QUERY "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
            { encoding: 'utf8', timeout: 5000, windowsHide: true }
        );
        const guid = out.split('REG_SZ')[1].replace(/\r+|\n+|\s+/gi, '').toLowerCase();
        _cachedHwid = crypto.createHash('sha256').update(guid).digest('hex');
        return _cachedHwid;
    } catch (_) {}
    _cachedHwid = crypto.createHash('sha256')
        .update(os.hostname() + (os.cpus()[0]?.model || '') + String(os.totalmem()))
        .digest('hex');
    return _cachedHwid;
}

async function checkLicenseWithServer(hwid, serverUrl) {
    try {
        const controller = new AbortController();
        const tid = setTimeout(() => controller.abort(), 8000);
        const res = await fetch(`${serverUrl}/api/license/status?hwid=${encodeURIComponent(hwid)}`, {
            signal: controller.signal,
        });
        clearTimeout(tid);
        if (!res.ok) return null;
        return await res.json();
    } catch (_) {
        return null;
    }
}

function transitionToMain() {
    if (licensePoller) { clearInterval(licensePoller); licensePoller = null; }
    if (activationWindow && !activationWindow.isDestroyed()) {
        activationWindow.close();
    }
    if (!mainWindow) createWindow();
}

function showActivationWindow(hwid) {
    activationWindow = new BrowserWindow({
        width: 580,
        height: 640,
        resizable: false,
        backgroundColor: '#0a0a0f',
        title: 'xponencIA — Activación',
        webPreferences: {
            preload: path.join(__dirname, 'preload-worker.js'),
            contextIsolation: true,
            nodeIntegration: false,
        },
        show: false,
    });

    activationWindow.loadFile(path.join(__dirname, 'activate-worker.html'));
    activationWindow.once('ready-to-show', () => activationWindow.show());
    activationWindow.on('closed', () => {
        activationWindow = null;
        // Si no hay ventana principal y no estamos en transición, salir
        if (!mainWindow && !isQuitting) {
            isQuitting = true;
            app.quit();
        }
    });
    Menu.setApplicationMenu(null);

    // Polling de aprobación cada 30 segundos
    licensePoller = setInterval(async () => {
        const env = readEnv();
        const serverUrl = env.TASK_SERVER_URL;
        if (!serverUrl) return;
        const status = await checkLicenseWithServer(hwid, serverUrl);
        if (status?.estado === 'aprobado') {
            transitionToMain();
        } else if (activationWindow && !activationWindow.isDestroyed()) {
            activationWindow.webContents.send('license-update', {
                estado: status?.estado || 'no_registrado',
            });
        }
    }, 30000);
}

// APP_DIR: directorio del código del app (resources/app/ en producción, raíz del proyecto en dev).
// Contiene archivos del bundle que NO son datos del usuario: version.json, .env.example, etc.
// Nunca se usa para guardar datos del usuario — solo para leer assets del bundle.
const APP_DIR = path.join(__dirname, '..', '..');

// SCRIPT_DIR: directorio de datos del usuario (sesiones, .env, schedule.json).
// Se elige según el modo de ejecución para separar código de datos:
//
//   ZIP portable  → carpeta del .exe  (PORTABLE_EXECUTABLE_DIR, junto a sessions/ y .env)
//   NSIS instalado → %AppData%\xponencIA Worker  (app.getPath('userData'))
//                    Separado de resources/app/ → las reinstalaciones no tocan los datos.
//   Dev (sin empaquetar) → raíz del proyecto (mismo comportamiento anterior)
const SCRIPT_DIR = process.env.PORTABLE_EXECUTABLE_DIR
    || (app.isPackaged ? app.getPath('userData') : APP_DIR);

// Asegurar que el directorio de datos existe (especialmente la primera vez en NSIS/portable).
fs.mkdirSync(SCRIPT_DIR, { recursive: true });

// Si no existe .env en SCRIPT_DIR, inicializarlo desde .env.example del bundle.
// Esto garantiza que el worker tenga TASK_SERVER_URL, BROWSER_ENGINE, etc. desde el
// primer arranque, sin que el usuario tenga que abrir Configuración → Guardar.
{
    const targetEnv = path.join(SCRIPT_DIR, '.env');
    if (!fs.existsSync(targetEnv)) {
        const candidates = [
            path.join(APP_DIR, '.env.example'),
            path.join(APP_DIR, '.env'),
        ];
        for (const src of candidates) {
            if (fs.existsSync(src)) { try { fs.copyFileSync(src, targetEnv); } catch (_) {} break; }
        }
    }
}

// Soporte para dev: WORKER_ENV_FILE puede ser path absoluto o relativo a SCRIPT_DIR
const ENV_PATH = (() => {
    const wef = process.env.WORKER_ENV_FILE;
    if (!wef) return path.join(SCRIPT_DIR, '.env');
    if (path.isAbsolute(wef)) return wef;           // ruta absoluta (dev-worker.js)
    return path.resolve(SCRIPT_DIR, wef);           // relativa al directorio de datos
})();
const SCHEDULE_PATH    = path.join(SCRIPT_DIR, 'schedule.json');
// Marker que persiste entre reinicios: al arrancar lo detectamos y mostramos "✓ Actualizado a vX.X"
const JUST_UPDATED_PATH = path.join(SCRIPT_DIR, '.just-updated');
// Versión aplicada que persiste hasta que version.json la refleje correctamente.
// Guardado en SCRIPT_DIR (AppData/Roaming — SIEMPRE escribible), no en APP_DIR
// que puede estar en una ubicación con permisos restringidos.
// Anti-loop: auto-update-check lee este archivo y lo usa como localVer aunque
// version.json no se haya podido actualizar (permisos, archivo en uso, etc.).
const APPLIED_VER_PATH  = path.join(SCRIPT_DIR, '.applied-version');
// Contador de fallos de inicio consecutivos — para safe mode y rollback automático.
const STARTUP_FAIL_PATH = path.join(SCRIPT_DIR, '.startup-fails');

// ── Safe mode + rollback automático ──────────────────────────────────────────
// Lee el contador de fallos previos ANTES de cualquier async o feature.
// Se incrementa inmediatamente; se resetea a 0 cuando el subprocess se registra.
let _consecFails = 0;
try {
    const raw = fs.existsSync(STARTUP_FAIL_PATH) ? fs.readFileSync(STARTUP_FAIL_PATH, 'utf8').trim() : '0';
    _consecFails = parseInt(raw, 10) || 0;
} catch (_) {}

// 3+ fallos consecutivos + worker.bak → restaurar versión anterior del worker.
// Este caso indica que el último patch rompió el subprocess (no el main process).
if (_consecFails >= 3) {
    const backupDir = path.join(APP_DIR, 'worker.bak');
    if (fs.existsSync(backupDir)) {
        try {
            const workerDir  = path.join(APP_DIR, 'worker');
            const brokenDir  = workerDir + '.broken';
            console.warn(`[ROLLBACK] ${_consecFails} fallos consecutivos + worker.bak encontrado — restaurando versión anterior`);
            if (fs.existsSync(brokenDir)) fs.rmSync(brokenDir, { recursive: true, force: true });
            fs.renameSync(workerDir, brokenDir);   // build roto → .broken (para diagnóstico)
            fs.renameSync(backupDir, workerDir);   // backup anterior → current
            _consecFails = 0; // restaurado → arrancar normalmente en este ciclo
            try { fs.writeFileSync(STARTUP_FAIL_PATH, '0', 'utf8'); } catch (_) {}
            console.warn('[ROLLBACK] ✓ Versión anterior del worker restaurada');
        } catch (rollbackErr) {
            console.error(`[ROLLBACK] Error al restaurar: ${rollbackErr.message}`);
        }
    }
}

// SAFE_MODE: saltar features experimentales (mitigaciones de energía, NIC)
// si hubo 2+ inicios fallidos consecutivos. Un worker degradado > un worker muerto.
const SAFE_MODE = _consecFails >= 2;
// Incrementar YA (antes de cualquier posible crash). Se resetea cuando el subprocess se registra.
try { fs.writeFileSync(STARTUP_FAIL_PATH, String(_consecFails + 1), 'utf8'); } catch (_) {}
if (SAFE_MODE) console.warn(`[SAFE MODE] ${_consecFails} fallos previos — features experimentales desactivados hasta que el worker se registre`);

// URL del servidor por defecto — igual al placeholder del input de configuración.
// Se escribe en .env al primer arranque si no está definida.
const DEFAULT_SERVER_URL = 'http://72.61.130.23:4010';

// ── Helpers ───────────────────────────────────────────────────────────────────

function readEnv() {
    const vars = {};
    if (!fs.existsSync(ENV_PATH)) return vars;
    fs.readFileSync(ENV_PATH, 'utf8').split('\n').forEach(line => {
        const m = line.match(/^\s*([^#\s][^=]*?)\s*=\s*(.*?)\s*$/);
        if (m) vars[m[1]] = m[2];
    });
    return vars;
}

function writeEnv(updates) {
    let content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
    for (const [k, v] of Object.entries(updates)) {
        const re = new RegExp(`^${k}=.*$`, 'm');
        if (re.test(content)) content = content.replace(re, `${k}=${v}`);
        else content += `\n${k}=${v}`;
    }
    fs.writeFileSync(ENV_PATH, content.trim() + '\n', 'utf8');
}

function getVersionJson() {
    // version.json es parte del bundle del app, no datos del usuario → APP_DIR, no SCRIPT_DIR.
    // Si estuviéramos en SCRIPT_DIR (AppData), quedaría desactualizado tras cada update.
    const p = path.join(APP_DIR, 'version.json');
    if (!fs.existsSync(p)) return { version: 'desconocida' };
    try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return { version: 'error' }; }
}

function resolveNodeBin() {
    const candidates = [
        'C:\\Program Files\\nodejs\\node.exe',
        'C:\\Program Files (x86)\\nodejs\\node.exe',
        path.join(SCRIPT_DIR, 'node.exe'),
    ];
    for (const p of candidates) if (fs.existsSync(p)) return p;
    try {
        const { execSync } = require('child_process');
        const found = execSync('where node', { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] })
            .trim().split('\n')[0].trim();
        if (found && fs.existsSync(found)) return found;
    } catch (_) {}
    return process.execPath;
}

const NODE_BIN = resolveNodeBin();

// ── Worker process management ─────────────────────────────────────────────────

function sendToRenderer(channel, data) {
    if (mainWindow && !mainWindow.isDestroyed()) {
        mainWindow.webContents.send(channel, data);
    }
}

// ── Diagnóstico de eventos del sistema ──────────────────────────────────────────
// Reporta un evento del sistema (suspend/resume/cambio de fuente, fallo de registro
// de inicio automático, etc.) a 3 destinos:
//   1. consola del proceso main
//   2. log de la GUI (sendToRenderer)
//   3. servidor (/api/worker/log) → queda persistido en worker_logs, visible en el
//      panel filtrando por worker. Esto permite correlacionar "worker desconectado"
//      con un evento real (suspensión, permisos insuficientes, etc.) sin depender
//      de que el afiliado reporte el problema manualmente.
// Es puramente observacional — no modifica ninguna configuración del sistema.
function reportWorkerEvent(tag, message, level = 'WARNING') {
    const line = `[${tag}] ${message}`;
    console.log(line);
    const rendererLevel = level === 'ERROR' ? 'error' : level === 'WARNING' ? 'system' : 'info';
    sendToRenderer('worker-log', { level: rendererLevel, msg: line });
    try {
        const env       = readEnv();
        const serverUrl = (env.TASK_SERVER_URL || '').replace(/\/$/, '');
        if (!serverUrl) return;
        fetch(`${serverUrl}/api/worker/log`, {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify({ workerId: getComputedWorkerId(), level, message: line }),
        }).catch(() => {});
    } catch (_) {}
}

function reportPowerEvent(message, level = 'WARNING') {
    reportWorkerEvent('POWER', message, level);
}

// Registra listeners de powerMonitor. Debe llamarse después de app.whenReady()
// (powerMonitor no está disponible antes). Sólo escucha eventos del SO; no cambia nada.
function setupPowerMonitor() {
    let powerMonitor;
    try { ({ powerMonitor } = require('electron')); } catch (_) { return; }
    if (!powerMonitor) return;

    powerMonitor.on('suspend', () => {
        // El SO se suspende/hiberna PESE a la mitigación → el afiliado forzó el sleep
        // (botón de apagado, Fn+sleep, política corporativa) o la mitigación no aplicó.
        // Nivel ERROR para que resalte en el panel.
        reportPowerEvent(`Sistema SUSPENDIDO pese a mitigación (blocker=${_powerBlockerId !== null}, noSleepPlan=${_noSleepApplied}) — el worker se pausa`, 'ERROR');
    });
    powerMonitor.on('resume', () => {
        reportPowerEvent('Sistema REANUDADO desde suspensión', 'INFO');
        // Notificar al afiliado: la suspensión interrumpió las tareas. Refuerza el
        // comportamiento (mantener la notebook encendida/enchufada) además del fix técnico.
        showSystemNotification(
            '⏸ El worker se pausó',
            'Tu PC se suspendió y se interrumpieron las tareas. Mantenela encendida y enchufada para no cortar la automatización.'
        );
    });
    powerMonitor.on('lock-screen', () => {
        reportPowerEvent('Pantalla BLOQUEADA (usuario presente, no necesariamente desconexión)', 'INFO');
    });
    powerMonitor.on('unlock-screen', () => {
        reportPowerEvent('Pantalla DESBLOQUEADA', 'INFO');
    });
    // Cambio de fuente de energía: si la desconexión coincide con on-battery, apunta a
    // un plan de energía agresivo en batería (la laptop se desenchufó).
    powerMonitor.on('on-battery', () => {
        reportPowerEvent('Cambió a BATERÍA (desenchufada)');
    });
    powerMonitor.on('on-ac', () => {
        reportPowerEvent('Cambió a CORRIENTE (enchufada)', 'INFO');
    });
    // shutdown sólo se emite en Linux/macOS; en Windows no llega, pero lo dejamos por completitud.
    powerMonitor.on('shutdown', () => {
        reportPowerEvent('Sistema APAGÁNDOSE/reiniciando');
    });

    console.log('[POWER] powerMonitor listeners registrados (suspend/resume/lock/battery)');
}

// ── Mitigación B: prevenir suspensión por cierre de tapa + standby + hibernación ──
// El powerSaveBlocker cubre el sleep por inactividad, pero NO el cierre de tapa.
// Acá ajustamos el plan de energía ACTIVO mientras el worker corre:
//   · Cerrar la tapa → "No hacer nada" (LIDACTION 0)
//   · Standby por inactividad → "Nunca" (standby-timeout 0)
//   · Hibernación → "Nunca" (hibernate-timeout 0) — esto cubre el caso en que
//     powercfg STANDBYIDLE=0 está activo pero Windows hiberna de todas formas
//     por timer de hibernación distinto o por batería crítica con "hibernate" de emergencia.
// Guardamos los valores previos en disco (sobrevive crashes) y los restauramos al parar.
// Los alias (SUB_BUTTONS, LIDACTION, SUB_SLEEP, STANDBYIDLE, SCHEME_CURRENT) NO son
// localizados → funciona en Windows en español/inglés/etc.
const POWERCFG_BACKUP_PATH = path.join(SCRIPT_DIR, '.powercfg-backup.json');
let _noSleepApplied = false;

function _runPowercfg(args) {
    try {
        execSync(`powercfg ${args}`, { encoding: 'utf8', timeout: 8000, windowsHide: true });
        return true;
    } catch (_) { return false; }
}

// Variante async de _runPowercfg — no bloquea el event loop del main thread.
// Usada por las funciones de APPLY (que se llaman durante el worker corriendo).
// Las funciones de RESTORE siguen usando la variante sync (se llaman al salir).
function _runPowercfgAsync(args) {
    return new Promise((resolve) => {
        require('child_process').exec(
            `powercfg ${args}`,
            { encoding: 'utf8', timeout: 8000, windowsHide: true },
            (err) => resolve(!err)
        );
    });
}

// Lee los índices Current AC/DC de un setting. Localización-proof: el texto de las
// etiquetas varía por idioma (ej. "Índice de configuración de corriente alterna actual"
// en español), pero el bloque siempre termina con dos valores 0x — AC y luego DC —
// después de las líneas de mín/máx/incremento o de los "posibles" valores enum.
// Por eso tomamos las DOS ÚLTIMAS coincidencias 0x del output.
function _powercfgQueryIndices(subAlias, settingAlias) {
    try {
        const out = execSync(`powercfg /q SCHEME_CURRENT ${subAlias} ${settingAlias}`,
            { encoding: 'utf8', timeout: 8000, windowsHide: true });
        const all = [...out.matchAll(/0x([0-9a-fA-F]+)/g)].map(m => parseInt(m[1], 16));
        if (all.length < 2) return null;   // setting ausente (ej. LIDACTION en un desktop)
        return { ac: all[all.length - 2], dc: all[all.length - 1] };
    } catch (_) { return null; }
}

// Variante async de _powercfgQueryIndices.
function _powercfgQueryIndicesAsync(subAlias, settingAlias) {
    return new Promise((resolve) => {
        require('child_process').exec(
            `powercfg /q SCHEME_CURRENT ${subAlias} ${settingAlias}`,
            { encoding: 'utf8', timeout: 8000, windowsHide: true },
            (err, out) => {
                if (err) return resolve(null);
                const all = [...out.matchAll(/0x([0-9a-fA-F]+)/g)].map(m => parseInt(m[1], 16));
                if (all.length < 2) return resolve(null);
                resolve({ ac: all[all.length - 2], dc: all[all.length - 1] });
            }
        );
    });
}

async function _applyNoSleepPlan() {
    if (process.platform !== 'win32') return;
    if (_noSleepApplied) return;
    _noSleepApplied = true;
    try {
        // Backup de valores previos — sólo si no existe ya (un crash anterior pudo dejarlo).
        // standby se guarda en SEGUNDOS (como lo reporta powercfg /q).
        if (!fs.existsSync(POWERCFG_BACKUP_PATH)) {
            const [lid, standby, hibernate] = await Promise.all([
                _powercfgQueryIndicesAsync('SUB_BUTTONS', 'LIDACTION'),
                _powercfgQueryIndicesAsync('SUB_SLEEP', 'STANDBYIDLE'),
                _powercfgQueryIndicesAsync('SUB_SLEEP', 'HIBERNATEIDLE'),
            ]);
            try {
                fs.writeFileSync(POWERCFG_BACKUP_PATH,
                    JSON.stringify({ lid, standby, hibernate, savedAt: Date.now() }, null, 2), 'utf8');
            } catch (_) {}
        }
        await _runPowercfgAsync('/setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0');
        await _runPowercfgAsync('/setdcvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION 0');
        await _runPowercfgAsync('/change standby-timeout-ac 0');
        await _runPowercfgAsync('/change standby-timeout-dc 0');
        await _runPowercfgAsync('/change hibernate-timeout-ac 0');
        await _runPowercfgAsync('/change hibernate-timeout-dc 0');
        await _runPowercfgAsync('/setactive SCHEME_CURRENT');
        reportPowerEvent('Mitigación anti-suspensión APLICADA (tapa=no-dormir, standby=nunca, hibernación=nunca)', 'INFO');
    } catch (e) {
        reportPowerEvent(`No se pudo aplicar mitigación anti-suspensión: ${e.message}`, 'ERROR');
    }
}

function _restorePowerPlan() {
    if (process.platform !== 'win32') return;
    if (!_noSleepApplied && !fs.existsSync(POWERCFG_BACKUP_PATH)) return;
    _noSleepApplied = false;
    try {
        let backup = null;
        try { backup = JSON.parse(fs.readFileSync(POWERCFG_BACKUP_PATH, 'utf8')); } catch (_) {}
        // Fallbacks seguros si el backup falló o no se pudo parsear el query:
        //   lid → 1 (Suspender = default de Windows; evita dejar la laptop sin dormir nunca)
        //   standby → 30 min AC / 15 min DC (defaults balanceados típicos)
        const lidAc       = backup?.lid?.ac ?? 1;
        const lidDc       = backup?.lid?.dc ?? 1;
        const standbyAc   = backup?.standby  ? Math.round(backup.standby.ac  / 60) : 30;
        const standbyDc   = backup?.standby  ? Math.round(backup.standby.dc  / 60) : 15;
        const hibernateAc = backup?.hibernate ? Math.round(backup.hibernate.ac / 60) : 0;
        const hibernateDc = backup?.hibernate ? Math.round(backup.hibernate.dc / 60) : 0;
        _runPowercfg(`/setacvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION ${lidAc}`);
        _runPowercfg(`/setdcvalueindex SCHEME_CURRENT SUB_BUTTONS LIDACTION ${lidDc}`);
        _runPowercfg(`/change standby-timeout-ac ${standbyAc}`);
        _runPowercfg(`/change standby-timeout-dc ${standbyDc}`);
        _runPowercfg(`/change hibernate-timeout-ac ${hibernateAc}`);
        _runPowercfg(`/change hibernate-timeout-dc ${hibernateDc}`);
        _runPowercfg('/setactive SCHEME_CURRENT');
        try { fs.unlinkSync(POWERCFG_BACKUP_PATH); } catch (_) {}
        reportPowerEvent('Plan de energía restaurado a los valores previos', 'INFO');
    } catch (_) {}
}

// ── Mitigación C: desactivar el ahorro de energía del adaptador de red (NIC) ───
// Causa raíz #2 de los zombies: Windows apaga el adaptador de red para ahorrar
// energía ("Permitir que el equipo apague este dispositivo para ahorrar energía"),
// el worker pierde la conexión, deja de latir, el server lo da por stale y, al
// reconectar, lanza un task nuevo sobre un perfil que el browser viejo todavía tiene.
// Acá desactivamos ese ahorro en los adaptadores activos mientras el worker corre, y
// lo restauramos al parar. Best-effort: Set-NetAdapterPowerManagement requiere admin;
// si no hay permisos, se loguea un warning y se sigue (experimental). Gateable por env.
const NIC_BACKUP_PATH = path.join(SCRIPT_DIR, '.nic-power-backup.json');
let _nicPowerApplied = false;

function _runPowerShell(script) {
    // -EncodedCommand (base64 UTF-16LE): evita el infierno de escapar comillas/backslashes.
    const encoded = Buffer.from(script, 'utf16le').toString('base64');
    return execSync(`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
        { encoding: 'utf8', timeout: 15000, windowsHide: true });
}

// Variante async de _runPowerShell — no bloquea el event loop del main thread.
function _runPowerShellAsync(script) {
    const encoded = Buffer.from(script, 'utf16le').toString('base64');
    return new Promise((resolve, reject) => {
        require('child_process').exec(
            `powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
            { encoding: 'utf8', timeout: 15000, windowsHide: true },
            (err, stdout) => err ? reject(err) : resolve(stdout)
        );
    });
}

async function _applyNicNoPowerSave() {
    if (process.platform !== 'win32') return;
    if (process.env.WORKER_NIC_ANTISLEEP === '0') return; // opt-out explícito
    if (_nicPowerApplied) return;
    _nicPowerApplied = true;
    try {
        // Adaptadores cuyo ahorro de energía está habilitado (los que vamos a tocar).
        // -AllowComputerToTurnOffDevice NO es localizable → funciona en cualquier idioma.
        const out = await _runPowerShellAsync(
            "$ErrorActionPreference='SilentlyContinue';" +
            "Get-NetAdapterPowerManagement | " +
            "Where-Object { $_.AllowComputerToTurnOffDevice -eq 'Enabled' } | " +
            "Select-Object -ExpandProperty Name"
        );
        const names = out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
        if (!fs.existsSync(NIC_BACKUP_PATH)) {
            try {
                fs.writeFileSync(NIC_BACKUP_PATH,
                    JSON.stringify({ names, savedAt: Date.now() }, null, 2), 'utf8');
            } catch (_) {}
        }
        if (!names.length) {
            reportPowerEvent('Mitigación C (NIC): ningún adaptador con ahorro de energía activo — nada que cambiar', 'INFO');
            return;
        }
        const set = names
            .map(n => `Set-NetAdapterPowerManagement -Name '${n.replace(/'/g, "''")}' -AllowComputerToTurnOffDevice Disabled -ErrorAction SilentlyContinue`)
            .join('; ');
        await _runPowerShellAsync(set);
        reportPowerEvent(`Mitigación C (NIC) aplicada: ahorro de energía desactivado en ${names.length} adaptador(es) [${names.join(', ')}]`, 'INFO');
    } catch (e) {
        // Típico: falta de permisos de admin. Best-effort experimental → warning y seguir.
        reportPowerEvent(`Mitigación C (NIC) no aplicada (¿sin permisos de admin?): ${e.message}`, 'WARNING');
    }
}

function _restoreNicPowerSave() {
    if (process.platform !== 'win32') return;
    if (!_nicPowerApplied && !fs.existsSync(NIC_BACKUP_PATH)) return;
    _nicPowerApplied = false;
    try {
        let backup = null;
        try { backup = JSON.parse(fs.readFileSync(NIC_BACKUP_PATH, 'utf8')); } catch (_) {}
        const names = backup?.names || [];
        if (names.length) {
            const set = names
                .map(n => `Set-NetAdapterPowerManagement -Name '${n.replace(/'/g, "''")}' -AllowComputerToTurnOffDevice Enabled -ErrorAction SilentlyContinue`)
                .join('; ');
            _runPowerShell(set);
        }
        try { fs.unlinkSync(NIC_BACKUP_PATH); } catch (_) {}
        reportPowerEvent('Mitigación C (NIC) revertida: ahorro de energía del adaptador restaurado', 'INFO');
    } catch (_) {}
}

/**
 * Muestra una notificación del sistema operativo (systray balloon en Windows,
 * notification center en macOS/Linux).
 * Usa la Notification API de Electron (no requiere paquetes externos).
 * Solo se muestra si la ventana principal no está enfocada, para no interrumpir
 * al usuario que ya está viendo la GUI.
 */
function showSystemNotification(title, body) {
    try {
        const { Notification: ElectronNotification } = require('electron');
        if (!ElectronNotification.isSupported()) return;
        const n = new ElectronNotification({
            title,
            body,
            icon: path.join(__dirname, 'icon.png'),
            urgency: 'critical',  // Linux: resalta en la barra
            timeoutType: 'never', // Windows: permanente hasta que el usuario la cierre
        });
        n.on('click', () => {
            // Al hacer click en la notificación, enfocar la ventana del worker
            if (mainWindow && !mainWindow.isDestroyed()) {
                if (mainWindow.isMinimized()) mainWindow.restore();
                mainWindow.focus();
            }
        });
        n.show();
    } catch (e) {
        // Si las notificaciones no están disponibles (ej. headless server), ignorar
        console.warn(`[notification] No se pudo mostrar notificación: ${e.message}`);
    }
}

function startWorker() {
    if (workerProcess) return { ok: false, error: 'El worker ya está corriendo' };
    _startPowerBlock();
    // Mitigaciones B y C son async (no bloquean el event loop).
    // Se saltean en SAFE_MODE (2+ fallos de inicio consecutivos) para garantizar
    // que al menos el subprocess spawne y se registre exitosamente.
    if (!SAFE_MODE) {
        (async () => {
            await _applyNoSleepPlan();
            await _applyNicNoPowerSave();
        })();
    } else {
        reportPowerEvent(`SAFE MODE activo (${_consecFails} fallos previos) — mitigaciones de energía desactivadas`, 'WARNING');
    }
    // startWorker.js es CÓDIGO del bundle → APP_DIR, no SCRIPT_DIR (que es el directorio de datos).
    // En ZIP portable: APP_DIR=resources/app/ mientras SCRIPT_DIR=directorio del exe.
    // En dev: ambos son ROOT, pero la distinción importa en producción.
    const scriptPath = path.join(APP_DIR, 'worker', 'startWorker.js');
    if (!fs.existsSync(scriptPath)) return { ok: false, error: `startWorker.js no encontrado en ${scriptPath}` };

    const env = { ...process.env, ...readEnv() };
    if (NODE_BIN === process.execPath) env.ELECTRON_RUN_AS_NODE = '1';
    env.WORKER_HWID = getWorkerHwid();
    // Asegurar que el worker use el mismo directorio de datos que la GUI (donde se guardan las sesiones).
    // Sin esto, workerConfig.js resuelve AppData (ruta de producción) en vez de SCRIPT_DIR,
    // y el worker no encuentra las sesiones guardadas por link-account.
    env.XPONENCIA_DATA_DIR = env.XPONENCIA_DATA_DIR || SCRIPT_DIR;

    workerProcess = spawn(NODE_BIN, [scriptPath], {
        cwd: SCRIPT_DIR,
        env,
        stdio: ['ignore', 'pipe', 'pipe'],
        // Ocultar la ventana de consola negra de Windows en builds empaquetados.
        // En dev (electron .) se mantiene visible para depuración.
        windowsHide: app.isPackaged,
    });

    workerProcess.stdout.on('data', d => {
        for (const line of d.toString().split('\n').filter(l => l.trim())) {
            // Señales de estado estructuradas — no van al log, actualizan la UI directamente
            if (line.startsWith('__WORKER_STATUS__:')) {
                // Formato: __WORKER_STATUS__:<código>:<payload opcional>
                // Para challenge_detected el payload es JSON (el alert object del servidor).
                const colonIdx    = line.indexOf(':');          // primera ':'
                const colonIdx2   = line.indexOf(':', colonIdx + 1); // segunda ':'
                const statusCode  = colonIdx2 === -1
                    ? line.slice(colonIdx + 1)
                    : line.slice(colonIdx + 1, colonIdx2);
                const rawPayload  = colonIdx2 === -1 ? '' : line.slice(colonIdx2 + 1);

                if (statusCode === 'ip_conflict') {
                    sendToRenderer('worker-status', {
                        running: true,
                        blocked: true,
                        blockReason: 'ip_conflict',
                        blockDetail: `Bloqueado — la IP ya está en uso por "${rawPayload || 'otro worker'}". Desconectá ese worker primero.`,
                    });
                } else if (statusCode === 'registered') {
                    sendToRenderer('worker-status', { running: true, blocked: false });
                    // Inicio exitoso → resetear el contador de fallos consecutivos
                    try { fs.writeFileSync(STARTUP_FAIL_PATH, '0', 'utf8'); } catch (_) {}

                } else if (statusCode === 'force_update') {
                    // El servidor solicitó actualización forzada — disparar ciclo de update en renderer
                    sendToRenderer('worker-log', { level: 'info', msg: '[UPDATE] Recibida señal force_update del servidor — iniciando actualización' });
                    sendToRenderer('force-update', {});

                } else if (statusCode === 'challenge_detected') {
                    // Alert de challenge/baneo recibido desde el servidor vía heartbeat
                    let alert = {};
                    try { alert = JSON.parse(rawPayload); } catch (_) {}

                    const cuenta    = alert.cuenta    || '(desconocida)';
                    const tipoCh    = alert.tipoCh    || 'challenge';
                    const isBan     = tipoCh === 'suspended';
                    const title     = isBan ? '🔴 Cuenta baneada' : '⚠️ Challenge detectado';
                    const body      = isBan
                        ? `@${cuenta} fue suspendida por Instagram — intervención requerida`
                        : `@${cuenta} requiere verificación en Instagram`;

                    // 1. Notificación del sistema operativo (systray balloon en Windows)
                    showSystemNotification(title, body);

                    // 2. Enviar al renderer para actualizar el panel de challenges
                    sendToRenderer('challenge-detected', { ...alert, title, body });

                    // 3. Log en la consola del worker
                    sendToRenderer('worker-log', { level: 'error', msg: `[CHALLENGE] ${body}` });
                }
                continue;
            }
            sendToRenderer('worker-log', { level: 'info', msg: line });
        }
    });
    workerProcess.stderr.on('data', d => {
        for (const line of d.toString().split('\n').filter(l => l.trim()))
            sendToRenderer('worker-log', { level: 'error', msg: line });
    });
    workerProcess.on('close', code => {
        workerProcess = null;
        sendToRenderer('worker-log', { level: 'system', msg: `Worker detenido (código: ${code ?? 'N/A'})` });
        if (!isQuitting) {
            sendToRenderer('worker-status', { running: false, reconnecting: true });
            sendToRenderer('worker-log', { level: 'system', msg: 'Reiniciando worker en 5 segundos...' });
            setTimeout(() => {
                if (!isQuitting && !workerProcess) {
                    sendToRenderer('worker-log', { level: 'system', msg: 'Reiniciando...' });
                    startWorker();
                }
            }, 5000);
        } else {
            sendToRenderer('worker-status', { running: false });
        }
    });
    workerProcess.on('error', err => {
        workerProcess = null;
        sendToRenderer('worker-status', { running: false });
        sendToRenderer('worker-log', { level: 'error', msg: `Error del worker: ${err.message}` });
    });

    sendToRenderer('worker-status', { running: true, pid: workerProcess.pid });
    return { ok: true, pid: workerProcess.pid };
}

function stopWorker() {
    if (!workerProcess) return { ok: false, error: 'No hay worker corriendo' };
    workerProcess.kill('SIGTERM');
    _stopPowerBlock();
    _restorePowerPlan();      // Revertir mitigación B al plan de energía previo
    _restoreNicPowerSave();   // Revertir mitigación C (NIC)
    return { ok: true };
}

// ── Link account (Playwright login manual) ─────────────────────────────────────

let linkBrowser = null;   // referencia para cancelación

ipcMain.handle('link-account', async (_, { account_type = 'prospectora', expected_username = null } = {}) => {
    const linkEnv            = readEnv();
    const usePlainPlaywright = (linkEnv.BROWSER_ENGINE || process.env.BROWSER_ENGINE) === 'playwright';

    const sessionsDirName = account_type === 'seguidora' ? 'sessions_seguidoras' : 'sessions';
    const sessionsDir = path.join(SCRIPT_DIR, sessionsDirName);
    fs.mkdirSync(sessionsDir, { recursive: true });

    const tmpName = `.tmp_login_${Date.now()}`;
    const tmpDir  = path.join(sessionsDir, tmpName);
    fs.mkdirSync(tmpDir, { recursive: true });

    console.log(`\n[LINK] ── Vinculando cuenta (tipo: ${account_type}) ──────────────`);
    console.log(`[LINK] Motor: ${usePlainPlaywright ? 'Playwright estándar' : 'CloakBrowser (Real PC Mode)'}`);
    console.log(`[LINK] Identidad: limpia (sin inyección de cookies del Chrome del usuario)`);
    sendToRenderer('link-account-progress', { status: 'opening', msg: 'Abriendo navegador...' });

    // ── a. Lanzar navegador ───────────────────────────────────────────────────
    let context;
    if (usePlainPlaywright) {
        // Patchright (→ fallback playwright) + stealth patch (BROWSER_ENGINE=playwright en .env)
        const { getChromium, applyPlaywrightStealth } = require('../realPcContext');
        const chromium = getChromium();
        const channel  = (linkEnv.BROWSER_CHANNEL || process.env.BROWSER_CHANNEL) || undefined;
        console.log(`[LINK] Lanzando Patchright + canal=${channel || 'chromium'} + stealth...`);
        context = await chromium.launchPersistentContext(tmpDir, {
            headless: false,
            channel,
            args: [
                '--disable-blink-features=AutomationControlled',
            ],
            viewport: null,   // null = Chrome decide el viewport según la ventana real
            locale: 'es-AR',
            timezoneId: 'America/Argentina/Buenos_Aires',
        });
        await applyPlaywrightStealth(context);
    } else {
        // CloakBrowser — Real PC Mode (modo por defecto)
        let launchPersistentContext;
        try {
            const cb = await import('cloakbrowser');
            launchPersistentContext = cb.launchPersistentContext;
        } catch (_) {
            return { ok: false, error: 'CloakBrowser no disponible. Reinstalá la aplicación.' };
        }

        const { REAL_PC_LAUNCH_OPTIONS, getLaunchArgs, applyRealPcPatches } = require('../realPcContext');
        // Pantalla en CSS pixels (Electron siempre devuelve valores lógicos, dpi-agnostic)
        const { screen: electronScreen } = require('electron');
        const primaryDisplay = electronScreen.getPrimaryDisplay();
        const cssScreen = { width: primaryDisplay.size.width, height: primaryDisplay.size.height };
        console.log(`[LINK] Pantalla detectada: ${cssScreen.width}x${cssScreen.height} CSS pixels`);
        // GPU: generada por el seed para mantener consistencia con canvas/audio.

        context = await launchPersistentContext({
            userDataDir: tmpDir,
            headless: false,
            ...REAL_PC_LAUNCH_OPTIONS,
            args: getLaunchArgs(tmpDir, cssScreen),
        });
        await applyRealPcPatches(context);
    }

    try {
        linkBrowser = context;

        // ── b. Navegar a Instagram ────────────────────────────────────────────
        // Contexto limpio: Instagram generará su propio ig_did y device cookies
        // de forma natural durante el login. No inyectamos nada del Chrome del
        // usuario para evitar colisión de identidad (el ig_did del usuario está
        // vinculado a su cuenta personal, no a la cuenta bot).
        sendToRenderer('link-account-progress', { status: 'opening', msg: 'Navegador listo — iniciá sesión en Instagram' });

        const page = context.pages()[0] || await context.newPage();
        console.log('[LINK] Navegando a instagram.com (contexto limpio)...');
        await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded' });
        await page.waitForTimeout(800 + Math.floor(Math.random() * 600));

        sendToRenderer('link-account-progress', {
            status: 'waiting',
            msg: expected_username
                ? `Iniciá sesión con @${expected_username} en Instagram.\nSe verificará que el usuario coincida — no cierres esta ventana.`
                : 'Iniciá sesión en Instagram.\nEl usuario se detectará automáticamente — no cierres esta ventana.',
        });

        let detected = null;
        const TIMEOUT = 6 * 60 * 1000;
        const start   = Date.now();

        while (!detected && !page.isClosed() && Date.now() - start < TIMEOUT) {
            await new Promise(r => setTimeout(r, 2000));
            if (page.isClosed()) break;

            const hasHome = await page.$([
                'svg[aria-label*="Inicio"]',
                'svg[aria-label*="Home"]',
                'a[href*="/direct/inbox/"]',
            ].join(',')).catch(() => null);
            if (!hasHome) continue;

            // Método 1: link de perfil con avatar en el nav
            detected = await page.evaluate(() => {
                const links = Array.from(document.querySelectorAll('a[href]'));
                const SKIP = new Set(['/', '/reels/', '/explore/', '/direct/', '/direct/inbox/',
                                      '/stories/', '/tv/', '/accounts/', '#']);
                for (const a of links) {
                    const href = a.getAttribute('href');
                    if (!href || SKIP.has(href) || href.startsWith('/accounts/')) continue;
                    const m = href.match(/^\/([a-z0-9._]+)\/?$/i);
                    if (m && a.querySelector('img')) return m[1];
                }
                return null;
            }).catch(() => null);

            // Método 2: API interna de Instagram
            if (!detected) {
                detected = await page.evaluate(async () => {
                    try {
                        const r = await fetch('/api/v1/accounts/current_user/?edit=true', {
                            credentials: 'include',
                            headers: { 'x-ig-app-id': '936619743392459' },
                        });
                        if (!r.ok) return null;
                        const d = await r.json();
                        return d?.user?.username || null;
                    } catch { return null; }
                }).catch(() => null);
            }
        }

        await context.close().catch(() => {});
        linkBrowser = null;

        if (!detected) {
            fs.rmSync(tmpDir, { recursive: true, force: true });
            return { ok: false, error: 'No se detectó la sesión. Intentá de nuevo.' };
        }

        // Si se esperaba un username específico, verificar que coincida
        if (expected_username && detected.toLowerCase() !== expected_username.toLowerCase()) {
            fs.rmSync(tmpDir, { recursive: true, force: true });
            return { ok: false, error: `Cuenta incorrecta: iniciaste sesión con @${detected} pero se esperaba @${expected_username}.` };
        }

        // Mover sesión al directorio definitivo.
        // Si ya existe una sesión previa (posiblemente contaminada de un intento
        // anterior), la REEMPLAZAMOS con la nueva sesión limpia.
        // El usuario completó el login manualmente → quiere esta sesión, no la vieja.
        const finalDir = path.join(sessionsDir, detected);
        const hadOldSession = fs.existsSync(finalDir);
        if (hadOldSession) {
            fs.rmSync(finalDir, { recursive: true, force: true });
        }
        try {
            fs.renameSync(tmpDir, finalDir);
        } catch (_) {
            // renameSync puede fallar cross-device — fallback a copy+delete
            fs.mkdirSync(finalDir, { recursive: true });
            const cpAll = (src, dst) => {
                for (const e of fs.readdirSync(src, { withFileTypes: true })) {
                    const s = path.join(src, e.name), d = path.join(dst, e.name);
                    e.isDirectory() ? (fs.mkdirSync(d, { recursive: true }), cpAll(s, d)) : fs.copyFileSync(s, d);
                }
            };
            cpAll(tmpDir, finalDir);
            fs.rmSync(tmpDir, { recursive: true, force: true });
        }

        console.log(`[LINK] ✓ Sesión de @${detected} guardada en: ${finalDir}`);
        if (hadOldSession) console.log(`[LINK]   (sesión anterior eliminada y reemplazada)`);
        sendToRenderer('link-account-progress', {
            status: 'saving',
            msg: hadOldSession
                ? `Sesión anterior de @${detected} reemplazada por sesión limpia ✓`
                : `Guardando sesión de @${detected}...`,
        });

        if (expected_username) {
            // Cuenta ya aprobada en el servidor — actualizar worker_id.
            // IMPORTANTE: no ignorar errores HTTP (ej. 409 = cuenta ya vinculada a otro worker).
            // Solo ignorar errores de RED (timeout, sin conexión).
            const env = readEnv();
            const serverUrl = env.TASK_SERVER_URL || 'http://72.61.130.23:4010';
            const workerId  = getComputedWorkerId(); // mismo ID que usa loadAccounts()
            let serverRes = null;
            try {
                serverRes = await fetch(`${serverUrl}/api/accounts`, {
                    method:  'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body:    JSON.stringify({ username: detected, worker_id: workerId, hwid: getWorkerHwid(), tipo_cuenta: account_type }),
                });
            } catch (_) {
                // Error de red — la sesión está guardada pero el servidor no pudo registrarse.
                // Continuamos (la cuenta ya estaba aprobada, el worker_id se actualizará en el próximo heartbeat).
                serverRes = null;
            }
            if (serverRes && !serverRes.ok) {
                const data = await serverRes.json().catch(() => ({}));
                // Borrar sesión local para no dejar la cuenta operando sin autorización del servidor
                try { fs.rmSync(finalDir, { recursive: true, force: true }); } catch (_) {}
                console.log(`[LINK] ✗ Servidor rechazó re-link de @${detected}: HTTP ${serverRes.status} — ${data.error || 'sin detalle'}`);
                return { ok: false, error: data.error || `Error ${serverRes.status} al registrar la cuenta en el servidor` };
            }
        } else {
            // Cuenta nueva — registrar y esperar aprobación del admin
            const env = readEnv();
            const serverUrl = env.TASK_SERVER_URL || 'http://72.61.130.23:4010';
            const workerId  = getComputedWorkerId(); // mismo ID que usa loadAccounts()
            const res = await fetch(`${serverUrl}/api/accounts`, {
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ username: detected, enabled: true, worker_id: workerId, hwid: getWorkerHwid(), tipo_cuenta: account_type }),
            });
            const data = await res.json().catch(() => ({}));
            if (!res.ok) {
                // El servidor rechazó el registro (ej. 409 = cuenta ya vinculada a otro worker,
                // 403 = límite de cuentas alcanzado). Limpiar la sesión guardada localmente
                // para no dejar datos inconsistentes ni permitir que el worker opere
                // con una cuenta que el servidor no autorizó.
                try { fs.rmSync(finalDir, { recursive: true, force: true }); } catch (_) {}
                console.log(`[LINK] ✗ Servidor rechazó @${detected}: HTTP ${res.status} — ${data.error || 'sin detalle'}`);
                return { ok: false, error: data.error || `Error ${res.status} al registrar la cuenta en el servidor` };
            }
        }

        return { ok: true, username: detected };

    } catch (err) {
        if (context) await context.close().catch(() => {});
        linkBrowser = null;
        try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
        return { ok: false, error: err.message };
    }
});

// ── IPC handlers ──────────────────────────────────────────────────────────────

ipcMain.handle('get-hwid', () => ({ hwid: getWorkerHwid() }));

// Calcula el worker ID con el mismo algoritmo que workerRuntime.js.
// Prioridad: WORKER_ID en .env → hostname-hwid[:8] → hostname-md5(hostname)[:8]
// IMPORTANTE: usarlo en TODOS los lugares que necesiten el worker ID (link-account,
// get-worker-id, etc.) para garantizar que siempre coincidan.
function getComputedWorkerId() {
    const env  = readEnv();
    if (env.WORKER_ID) return env.WORKER_ID;
    const hwid = getWorkerHwid();
    const hostname = os.hostname();
    const suffix = hwid
        ? hwid.slice(0, 8)
        : require('crypto').createHash('md5').update(hostname).digest('hex').slice(0, 8);
    return `${hostname}-${suffix}`;
}

ipcMain.handle('get-worker-id', () => ({ workerId: getComputedWorkerId() }));

ipcMain.handle('check-license-status', async () => {
    const env = readEnv();
    const hwid = getWorkerHwid();
    const serverUrl = env.TASK_SERVER_URL;
    if (!serverUrl) return { ok: false, error: 'Sin URL de servidor configurada' };
    const status = await checkLicenseWithServer(hwid, serverUrl);
    if (!status) return { ok: false, error: 'No se pudo conectar al servidor' };
    // Si ya fue aprobado, transicionar de inmediato (sin esperar al poller)
    if (status.estado === 'aprobado') {
        setImmediate(() => transitionToMain());
    }
    return { ok: true, ...status };
});

ipcMain.handle('submit-license-request', async (_, { nombre, email, telefono, direccion } = {}) => {
    const env = readEnv();
    const hwid = getWorkerHwid();
    const serverUrl = env.TASK_SERVER_URL;
    if (!serverUrl) return { ok: false, error: 'Sin URL de servidor configurada' };
    try {
        const res = await fetch(`${serverUrl}/api/license/request`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ hwid, nombre, email, telefono, direccion }),
        });
        const data = await res.json();
        return { ok: res.ok, ...data };
    } catch (e) {
        return { ok: false, error: e.message };
    }
});

ipcMain.handle('get-env', () => readEnv());
ipcMain.handle('save-env', (_, updates) => { writeEnv(updates); return { ok: true }; });
ipcMain.handle('get-version', () => getVersionJson());
ipcMain.handle('get-is-dev', () => ({
    dev: !!(process.env.WORKER_ENV_FILE || process.env.NODE_ENV === 'development'),
}));

ipcMain.handle('get-schedule', () => {
    if (!fs.existsSync(SCHEDULE_PATH)) return null;
    try { return JSON.parse(fs.readFileSync(SCHEDULE_PATH, 'utf8')); } catch (_) { return null; }
});
// Solo escribe el archivo local — NO envía al servidor (usado por loadSchedule para caché offline).
ipcMain.handle('save-schedule-local', (_, data) => {
    try { fs.writeFileSync(SCHEDULE_PATH, JSON.stringify(data, null, 2), 'utf8'); } catch (_) {}
});

ipcMain.handle('save-schedule', async (_, data) => {
    // Orden: sincronizar PRIMERO con el servidor (fuente de verdad — el scheduler lee
    // ig_workers.schedule en DB, NO el archivo local). Solo después se persiste local.
    //   • 2xx        → guardar local + serverSync:true
    //   • 4xx        → agenda inválida (ej. <3 horarios/día): NO persistir local
    //                   divergente; devolver el motivo real para mostrarlo al afiliado.
    //   • red caída  → guardar local igual (modo offline) con el error de red.
    let serverSync  = false;
    let serverError = null;
    try {
        const env       = readEnv();
        const serverUrl = (env.TASK_SERVER_URL || '').replace(/\/$/, '');
        const workerId  = getComputedWorkerId();
        if (serverUrl && workerId) {
            const res = await fetch(`${serverUrl}/api/workers/${encodeURIComponent(workerId)}/schedule`, {
                method:  'PUT',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ schedule: data }),
            });
            if (res.ok) {
                serverSync = true;
                console.log(`[save-schedule] Agenda sincronizada con servidor para worker ${workerId}`);
            } else {
                const err = await res.json().catch(() => ({}));
                serverError = err.error || `HTTP ${res.status}`;
                console.warn(`[save-schedule] Servidor respondió ${res.status}: ${serverError}`);
                // Rechazo de validación (4xx): la agenda es inválida → no guardar local.
                if (res.status >= 400 && res.status < 500) {
                    return { ok: false, serverSync: false, serverError };
                }
            }
        }
    } catch (e) {
        // Error de red no es crítico — la agenda queda guardada localmente (offline).
        serverError = e.message;
        console.warn(`[save-schedule] No se pudo sincronizar con servidor: ${e.message}`);
    }

    fs.writeFileSync(SCHEDULE_PATH, JSON.stringify(data, null, 2), 'utf8');
    return { ok: true, serverSync, serverError };
});
ipcMain.handle('worker-start', () => startWorker());
ipcMain.handle('worker-stop', () => stopWorker());
ipcMain.handle('worker-is-running', () => ({ running: !!workerProcess }));

// Lista los perfiles de Chrome instalados en la máquina (para importar sesión)
ipcMain.handle('list-chrome-profiles', () => {
    try {
        const { listChromeProfiles } = require('../chromeImporter');
        return { ok: true, profiles: listChromeProfiles() };
    } catch (e) {
        return { ok: false, error: e.message, profiles: [] };
    }
});

/**
 * Diagnostica la sesión de una cuenta: lee las cookies del perfil de CloakBrowser
 * guardado en sessions/<username>/ y muestra qué hay (ig_did, sessionid, etc.).
 * Usa el mismo lector SQLite del chromeImporter pero con la clave del perfil local.
 */
ipcMain.handle('diagnose-session', async (_, { username, account_type }) => {
    const dirName = account_type === 'seguidora' ? 'sessions_seguidoras' : 'sessions';
    const sessionDir = path.join(SCRIPT_DIR, dirName, username);

    if (!fs.existsSync(sessionDir)) {
        return { ok: false, error: 'No existe sesión local para esta cuenta' };
    }

    try {
        // CloakBrowser guarda las cookies en el mismo formato que Chrome:
        // Local State → clave AES encriptada con DPAPI del usuario Windows
        // Network/Cookies (o Cookies) → SQLite con cookies encriptadas
        const { dpApiDecryptFromLocalState, parseSQLiteCookiesWithKey } = require('../sessionDiag');
        const result = await dpApiDecryptFromLocalState(sessionDir);
        return { ok: true, ...result };
    } catch (e) {
        return { ok: false, error: e.message };
    }
});

// Proxy de llamadas a la API del servidor
ipcMain.handle('server-api', async (_, { method, path: apiPath, body }) => {
    const env = readEnv();
    const serverUrl = env.TASK_SERVER_URL || 'http://72.61.130.23:4010';
    try {
        const opts = {
            method: method || 'GET',
            headers: { 'Content-Type': 'application/json' },
        };
        if (body) opts.body = JSON.stringify(body);
        const res  = await fetch(`${serverUrl}${apiPath}`, opts);
        const text = await res.text();
        let data;
        try { data = JSON.parse(text); } catch (_) { data = { _raw: text.slice(0, 200) }; }
        return { ok: res.ok, status: res.status, data };
    } catch (err) {
        return { ok: false, error: err.message };
    }
});

ipcMain.handle('open-env-file', () => {
    shell.openPath(ENV_PATH);
});

ipcMain.handle('open-external', (_, url) => {
    shell.openExternal(url);
});

// ── Tray popup IPC ────────────────────────────────────────────────────────────
ipcMain.handle('open-main-window', () => {
    if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
    else createWindow();
    if (popupWindow) popupWindow.hide();
});

ipcMain.handle('hide-popup', () => {
    if (popupWindow) popupWindow.hide();
});

ipcMain.handle('open-link-from-popup', () => {
    if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
    else createWindow();
    if (popupWindow) popupWindow.hide();
    // Tell the renderer to open the link modal once the window is ready
    mainWindow?.webContents?.send('open-link-modal');
});

ipcMain.handle('get-worker-state', () => ({
    running: !!workerProcess,
    status:  workerProcess ? 'running' : 'idle',
    statusText: workerProcess ? 'Activo' : 'Inactivo',
}));

// Verifica si existe sesión local para un conjunto de usernames
ipcMain.handle('check-local-sessions', (_, usernames) => {
    const result = {};
    for (const username of usernames) {
        const dirs = [
            path.join(SCRIPT_DIR, 'sessions', username),
            path.join(SCRIPT_DIR, 'sessions_seguidoras', username),
        ];
        const found = dirs.find(d => {
            try { return fs.existsSync(d) && fs.readdirSync(d).length > 0; } catch (_) { return false; }
        });
        result[username] = found ? 'ok' : 'missing';
    }
    return result;
});

// Devuelve todos los usernames que tienen sesión local en esta PC,
// independientemente de si están en el servidor o no.
// Usado para detectar sesiones "huérfanas" (local pero no en servidor con este worker).
ipcMain.handle('get-all-local-sessions', () => {
    const sessions = {};
    for (const [dirName, tipo] of [['sessions', 'prospectora'], ['sessions_seguidoras', 'seguidora']]) {
        const dir = path.join(SCRIPT_DIR, dirName);
        if (!fs.existsSync(dir)) continue;
        try {
            for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
                if (!entry.isDirectory()) continue;
                const sessionPath = path.join(dir, entry.name);
                try {
                    if (fs.readdirSync(sessionPath).length > 0) {
                        sessions[entry.name] = tipo;
                    }
                } catch (_) {}
            }
        } catch (_) {}
    }
    return sessions;
});

// Resetea la sesión local de un username (borra carpeta) y notifica al servidor
// para marcar la cuenta como "Sin sesión" (confirmado=false), SIN tocar el flag
// "Habilitada" (enabled). Ambas columnas son independientes.
ipcMain.handle('reset-local-session', async (_, { username, account_type }) => {
    const dirName = account_type === 'seguidora' ? 'sessions_seguidoras' : 'sessions';
    const sessionDir = path.join(SCRIPT_DIR, dirName, username);

    // 1. Borrar sesión local
    try {
        if (fs.existsSync(sessionDir)) {
            fs.rmSync(sessionDir, { recursive: true, force: true });
        }
    } catch (err) {
        return { ok: false, error: err.message };
    }

    // 2. Notificar al servidor: marcar SIN sesión (confirmado=false), NO deshabilitar
    // Se usa reset_session:true para que el servidor solo toque la columna de sesión
    // y deje intacto el flag enabled (Habilitada/Deshabilitada lo gestiona el admin).
    try {
        const env       = readEnv();
        const serverUrl = env.TASK_SERVER_URL;
        if (serverUrl) {
            await fetch(`${serverUrl}/api/accounts`, {
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ username, reset_session: true }),
            });
            console.log(`[RESET-SESSION] @${username} marcada sin sesión en el servidor`);
        }
    } catch (e) {
        // Error de red no es crítico — la sesión local ya fue borrada
        console.log(`[RESET-SESSION] ⚠ No se pudo notificar al servidor: ${e.message}`);
    }

    return { ok: true };
});

// ── Telemetría visual: captura del PANEL (ventana GUI del worker) ──────────────
// El proceso main es el único siempre vivo (el subproceso del worker solo existe
// durante tareas). Por eso la captura de la ventana GUI la maneja acá: pooleamos al
// servidor cada 15s y, cuando hay una solicitud pendiente, capturamos la ventana con
// capturePage() y la subimos (target 'panel'). La captura del Chrome de Playwright la
// maneja el subproceso por heartbeat (target 'chrome'), con su propio flag.
let _shotPollerTimer = null;
function startScreenshotPoller() {
    if (_shotPollerTimer) return;
    const { uploadBuffer } = require('../screenshotReporter');
    _shotPollerTimer = setInterval(async () => {
        try {
            if (!mainWindow || mainWindow.isDestroyed()) return;
            const env = readEnv();
            const serverUrl = (env.TASK_SERVER_URL || '').replace(/\/$/, '');
            if (!serverUrl) return;
            const workerId = getComputedWorkerId();
            const res = await fetch(`${serverUrl}/api/workers/${encodeURIComponent(workerId)}/screenshot-poll`, {
                method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
            }).catch(() => null);
            if (!res || !res.ok) return;
            const data = await res.json().catch(() => ({}));
            if (!data.capture) return;
            // Capturar la ventana GUI y subirla como target 'panel'
            const img = await mainWindow.webContents.capturePage();
            const buf = img.toJPEG(60);
            const up = await uploadBuffer(buf, { reason: 'on_demand', target: 'panel', workerId, serverUrl });
            if (up.ok) console.log('[SHOT] 📸 Panel (GUI) capturado y subido');
            else console.log(`[SHOT] Upload del panel falló: ${up.error}`);
        } catch (_) { /* silencioso — nunca romper por una captura */ }
    }, 15000);
}

// ── Heartbeat del proceso main — independiente del worker subprocess ──────────
// Envía un ping al servidor cada 30s sin importar si el subprocess está corriendo.
// Permite distinguir dos estados desde el panel:
//   main_last_seen_at reciente + last_seen_at viejo → subprocess caído, puede auto-parcharse
//   ambos viejos → Electron muerto, necesita intervención manual
let _mainHeartbeatTimer = null;
function startMainHeartbeat() {
    if (_mainHeartbeatTimer) return;
    _mainHeartbeatTimer = setInterval(async () => {
        try {
            const env = readEnv();
            const serverUrl = (env.TASK_SERVER_URL || '').replace(/\/$/, '');
            if (!serverUrl) return;
            const controller = new AbortController();
            const tid = setTimeout(() => controller.abort(), 5000);
            await fetch(
                `${serverUrl}/api/workers/${encodeURIComponent(getComputedWorkerId())}/main-ping`,
                {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        source: 'main',
                        workerAlive: !!workerProcess && !workerProcess.killed,
                        version: getVersionJson().version,
                    }),
                    signal: controller.signal,
                }
            ).catch(() => {});
            clearTimeout(tid);
        } catch (_) {}
    }, 30000);
}

// ── Auto-updater ──────────────────────────────────────────────────────────────
// Descarga la actualización via Node.js (no Chrome) → sin bloqueos de AV por download de browser.
// Extrae con tar.exe (nativo en Windows 10+). Aplica al reiniciar.
// Solo funciona para WORKER_VARIANT=zip (portable ZIP).

const https   = require('https');
const http    = require('http');
const zlib    = require('zlib');

let _pendingUpdatePath    = null;   // ruta del ZIP descargado listo para aplicar
let _pendingUpdateIsPatch = false;  // true = patch ZIP (sin node_modules), false = ZIP completo

ipcMain.handle('auto-update-check', async () => {
    try {
        const env = readEnv();
        const serverUrl = (env.TASK_SERVER_URL || '').replace(/\/$/, '');
        if (!serverUrl) return { available: false, error: 'No hay servidor configurado' };

        // Leer versión desde version.json (puede estar desactualizado si no tuvimos permisos de escritura)
        let localVer = (() => {
            try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'version.json'), 'utf8')).version || '0.0.0'; }
            catch (_) { return '0.0.0'; }
        })();

        // ANTI-LOOP: si existe un .applied-version en AppData (escrito antes de salir),
        // úsalo como localVer aunque version.json no se haya podido actualizar.
        // También intenta reparar version.json en este momento.
        try {
            if (fs.existsSync(APPLIED_VER_PATH)) {
                const appliedVer = fs.readFileSync(APPLIED_VER_PATH, 'utf8').trim();
                if (appliedVer && appliedVer !== '1') {
                    // Intentar reparar version.json (puede fallar por permisos)
                    try {
                        const vPath = path.join(APP_DIR, 'version.json');
                        const vObj = (() => { try { return JSON.parse(fs.readFileSync(vPath, 'utf8')); } catch (_) { return {}; } })();
                        if (vObj.version !== appliedVer) {
                            vObj.version = appliedVer;
                            fs.writeFileSync(vPath, JSON.stringify(vObj, null, 2) + '\n', 'utf8');
                        }
                    } catch (_) {}
                    // Independientemente de si la reparación funcionó,
                    // usar appliedVer como localVer para evitar el loop.
                    const parseVerQuick = (v) => {
                        const m = v.match(/^(\d{4})\.(\d{1,2})\.(\d{2})-(\d{2})(\d{2})$/);
                        if (!m) return 0;
                        return new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3]), parseInt(m[4]), parseInt(m[5])).getTime();
                    };
                    if (parseVerQuick(appliedVer) >= parseVerQuick(localVer)) {
                        localVer = appliedVer;
                    }
                    // Borrar el marker solo si version.json ya refleja la versión aplicada
                    try {
                        const currentInFile = JSON.parse(fs.readFileSync(path.join(APP_DIR, 'version.json'), 'utf8')).version;
                        if (currentInFile === appliedVer) fs.unlinkSync(APPLIED_VER_PATH);
                    } catch (_) {}
                }
            }
        } catch (_) {}

        const remote = await new Promise((resolve, reject) => {
            const url = serverUrl + '/api/version';
            const mod = url.startsWith('https') ? https : http;
            const req = mod.get(url, { timeout: 8000 }, (res) => {
                let data = '';
                res.on('data', d => data += d);
                res.on('end', () => {
                    try { resolve(JSON.parse(data)); } catch (_) { resolve({}); }
                });
            });
            req.on('error', reject);
            req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
        });

        const remoteVer = remote.version || '0.0.0';
        // Comparar versiones: solo actualizar si la remota es MAYOR que la local.
        // Formato: "YYYY.M.DD-HHMM" — parseamos a timestamp para comparar correctamente.
        // (string comparison falla cuando cambia el mes: "2026.10.01" < "2026.5.01" por comparación de chars)
        const parseVer = (v) => {
            const m = v.match(/^(\d{4})\.(\d{1,2})\.(\d{2})-(\d{2})(\d{2})$/);
            if (!m) return 0;
            return new Date(
                parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]),
                parseInt(m[4]), parseInt(m[5])
            ).getTime();
        };
        const available = remoteVer !== 'unknown' && parseVer(remoteVer) > parseVer(localVer);
        return { available, localVer, remoteVer, serverUrl };
    } catch (e) {
        return { available: false, error: e.message };
    }
});

ipcMain.handle('auto-update-download', async (event, { serverUrl, usePatch = false, fullZipFile = null, remoteVer = null }) => {
    const env = readEnv();
    const variant = (env.WORKER_VARIANT || 'zip').trim();

    // Elegir qué descargar:
    //   - patch ZIP (solo código .js, sin node_modules) cuando usePatch=true y variante zip
    //   - ZIP completo: usa fullZipFile del /api/version (ej. "xponencia-latest-win.zip"),
    //     con fallback a "latest-win.zip" para compatibilidad con servidores viejos
    let releaseFile;
    if (variant !== 'zip') {
        releaseFile = 'latest-setup.exe';
    } else if (usePatch) {
        releaseFile = 'latest-win-patch.zip';
    } else {
        releaseFile = fullZipFile || 'xponencia-latest-win.zip';
    }
    const downloadUrl = serverUrl + '/releases/' + releaseFile;

    const tmpDir = path.join(os.tmpdir(), 'xponencia-update');
    fs.mkdirSync(tmpDir, { recursive: true });
    const tmpFile  = path.join(tmpDir, releaseFile);
    const metaFile = tmpFile + '.meta.json';

    // Descarga con reanudación (HTTP Range) y reintentos — ver updateDownloader.js.
    // Si se corta, el parcial queda en tmpDir y el próximo intento (o el próximo
    // ciclo de 30 min) continúa desde donde quedó en vez de arrancar de cero.
    // remoteVer invalida parciales de una versión anterior del release.
    const r = await downloadRelease({
        url:      downloadUrl,
        destFile: tmpFile,
        metaFile,
        remoteVer,
        onProgress: ({ pct, received, total }) => {
            if (mainWindow) mainWindow.webContents.send('update-progress', { pct, received, total });
        },
        log: (msg) => console.log(`[UPDATE] ${releaseFile}: ${msg}`),
    });

    if (!r.ok) return { ok: false, error: r.error, fatal: !!r.fatal, releaseFile };

    _pendingUpdatePath = tmpFile;
    _pendingUpdateIsPatch = usePatch && variant === 'zip';
    return { ok: true, path: tmpFile, variant, isPatch: _pendingUpdateIsPatch, releaseFile };
});

ipcMain.handle('auto-update-apply', async (event, { remoteVer = null } = {}) => {
    if (!_pendingUpdatePath || !fs.existsSync(_pendingUpdatePath)) {
        return { ok: false, error: 'No hay actualización descargada' };
    }

    const zipPath = _pendingUpdatePath;
    const env = readEnv();
    const variant = (env.WORKER_VARIANT || 'zip').trim();

    if (variant === 'zip') {
        // Para ZIP portable: extraer con PowerShell y reiniciar.
        //
        // ESTRUCTURA DE CADA ZIP:
        //   Patch (latest-win-patch.zip): app/ → app/worker/, app/src/, app/version.json, …
        //     Destino correcto: APP_DIR  (resources/app/)
        //     Después de strip: worker/, src/, version.json, … en APP_DIR  ✓
        //
        //   Full  (latest-win.zip):  xponencIA Worker/ → xponencIA Worker.exe, resources/, locales/, …
        //     Destino correcto: directorio del .exe  (un nivel arriba de resources/)
        //     Después de strip: xponencIA Worker.exe, resources/, locales/ en exe_dir  ✓
        //
        // TÉCNICA: no usamos tar (falla con ZIPs en Windows).
        // Usamos PowerShell con Expand-Archive → temp dir → Copy-Item del directorio interno.
        // Esto equivale a --strip-components=1 sin depender de tar.
        const zipPathW  = zipPath.replace(/\//g, '\\');
        const exePathW  = app.getPath('exe').replace(/\//g, '\\');
        const isPatch   = _pendingUpdateIsPatch;
        const appDirW   = APP_DIR.replace(/\//g, '\\');
        const exeDirW   = path.dirname(app.getPath('exe')).replace(/\//g, '\\');

        // Destino de extracción según tipo de ZIP
        const extractDest = isPatch ? appDirW : exeDirW;

        // ── ANTI-LOOP: pre-escribir version.json en APP_DIR antes de salir ──────
        // Garantiza que incluso si el script PowerShell falla al extraer el ZIP
        // (estructura inesperada, permisos, etc.), la nueva versión quede registrada
        // y el worker NO entre en un bucle descargando la misma actualización.
        // El PS1 sobreescribe este archivo con el contenido completo del ZIP,
        // que en condiciones normales contiene los mismos campos.
        let actualRemoteVer = remoteVer;
        if (!actualRemoteVer) {
            // Fallback: intentar leer version.json del ZIP antes de extraerlo.
            // Usamos PowerShell ZipFile para no necesitar dependencias extra.
            try {
                const psReadVer = [
                    `Add-Type -Assembly 'System.IO.Compression.FileSystem'`,
                    `$z = [System.IO.Compression.ZipFile]::OpenRead('${zipPathW.replace(/'/g, "''")}')`,
                    `$e = $z.Entries | Where-Object { $_.FullName -match 'version\\.json$' -and $_.FullName -notmatch 'node_modules' } | Select-Object -First 1`,
                    `if ($e) { $r = New-Object System.IO.StreamReader($e.Open()); Write-Output $r.ReadToEnd(); $r.Close() }`,
                    `$z.Dispose()`,
                ].join('; ');
                const { spawnSync } = require('child_process');
                const res = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psReadVer], { encoding: 'utf8', timeout: 5000, windowsHide: true });
                const parsed = JSON.parse(res.stdout || '{}');
                if (parsed.version) actualRemoteVer = parsed.version;
            } catch (_) {}
        }
        if (actualRemoteVer) {
            try {
                const existing = (() => { try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'version.json'), 'utf8')); } catch (_) { return {}; } })();
                existing.version = actualRemoteVer;
                fs.writeFileSync(path.join(APP_DIR, 'version.json'), JSON.stringify(existing, null, 2) + '\n', 'utf8');
            } catch (_) {}
        }

        // ── Script PowerShell (archivo .ps1 separado para evitar infierno de escapado en .bat)
        // Algoritmo:
        //   1. Expand-Archive al temp dir
        //   2. Obtener el único subdirectorio raíz (el nivel que se "stripea")
        //   3. Copy-Item del contenido del subdirectorio al destino real
        //   4. Limpiar temp
        const ps1Path = path.join(os.tmpdir(), 'xponencia-apply-update.ps1');
        // Escapar comillas simples en las rutas (por si acaso hay apostrofes en el path)
        const zipSafe     = zipPathW.replace(/'/g, "''");
        const destSafe    = extractDest.replace(/'/g, "''");
        const appDirSafe  = appDirW.replace(/'/g, "''");
        const remoteVerSafe = (actualRemoteVer || '').replace(/'/g, "''");
        const ps1Lines = [
            `$zipPath     = '${zipSafe}'`,
            `$extractDest = '${destSafe}'`,
            `$appDir      = '${appDirSafe}'`,
            `$remoteVer   = '${remoteVerSafe}'`,
            // Para patches: backup de worker/ antes de extraer.
            // Si el nuevo build crashea (safe mode detecta 3+ fallos), el main process
            // restaura worker.bak automáticamente en el próximo arranque.
            ...(isPatch ? [
                `$workerBak = Join-Path $appDir 'worker.bak'`,
                `$workerCur = Join-Path $appDir 'worker'`,
                `if (Test-Path $workerCur) {`,
                `    if (Test-Path $workerBak) { Remove-Item $workerBak -Recurse -Force -ErrorAction SilentlyContinue }`,
                `    try { Copy-Item -Path $workerCur -Destination $workerBak -Recurse -Force -ErrorAction SilentlyContinue } catch {}`,
                `}`,
            ] : []),
            `$tmp = Join-Path $env:TEMP 'xponencia-extract-tmp'`,
            `if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force }`,
            `New-Item -ItemType Directory -Force $tmp | Out-Null`,
            `Expand-Archive -LiteralPath $zipPath -DestinationPath $tmp -Force`,
            // Soportar dos estructuras:
            // 1. ZIP con carpeta raíz (normal): "xponencIA Worker/" o "app/"
            // 2. ZIP plano (repack falló): archivos directamente en el root
            `$rootDirs = Get-ChildItem $tmp -Directory`,
            `$rootFiles = Get-ChildItem $tmp -File`,
            `if ($rootDirs.Count -eq 1 -and $rootFiles.Count -eq 0) {`,
            `    $inner = $rootDirs[0].FullName`,
            `    Get-ChildItem -Path $inner | Copy-Item -Destination $extractDest -Recurse -Force`,
            `} elseif ($rootDirs.Count -gt 0 -or $rootFiles.Count -gt 0) {`,
            `    Get-ChildItem -Path $tmp | Copy-Item -Destination $extractDest -Recurse -Force`,
            `} else {`,
            `    Write-Error 'ZIP vacio o estructura no reconocida'; exit 1`,
            `}`,
            `Remove-Item $tmp -Recurse -Force`,
            // ── ANTI-LOOP: forzar version.json con remoteVer DESPUÉS de la extracción ──
            // El ZIP puede tener una version.json desactualizada (ej. full ZIP nunca rebuildeado).
            // Este paso garantiza que APP_DIR/version.json tenga la versión que el servidor
            // reportó, sin importar qué venía en el ZIP. Sin esto, el worker entra en bucle.
            // Nota: usamos [IO.File]::WriteAllText en vez de Set-Content para evitar BOM en
            // PowerShell 5.1 (-Encoding utf8 agrega BOM, rompiendo JSON.parse en Node.js).
            `if ($remoteVer -ne '') {`,
            `    $vPath = Join-Path $appDir 'version.json'`,
            `    if (Test-Path $vPath) {`,
            `        try {`,
            `            $vObj = Get-Content -LiteralPath $vPath -Raw | ConvertFrom-Json`,
            `            $vObj | Add-Member -MemberType NoteProperty -Name 'version' -Value $remoteVer -Force`,
            `            $json = $vObj | ConvertTo-Json -Depth 10 -Compress`,
            `            [IO.File]::WriteAllText($vPath, $json, [Text.Encoding]::UTF8)`,
            `        } catch {}`,
            `    }`,
            `}`,
            `exit 0`,
        ];
        fs.writeFileSync(ps1Path, ps1Lines.join('\r\n'), 'utf8');

        // ── Bat: espera → PowerShell → reinicia
        // Se ejecuta SIN ventana (lanzado por wscript con window style 0, ver abajo).
        // Por eso NO debe contener 'pause' ni nada interactivo: colgaría el proceso
        // de forma invisible. Los errores se registran en un log en SCRIPT_DIR.
        const logPathW = path.join(SCRIPT_DIR, 'update-error.log').replace(/\//g, '\\');
        const batPath = path.join(os.tmpdir(), 'xponencia-apply-update.bat');
        const batLines = [
            '@echo off',
            'timeout /t 3 /nobreak > nul',
            `powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "${ps1Path}"`,
            'if errorlevel 1 (',
            `    echo [%date% %time%] Update apply fallo (${isPatch ? 'patch' : 'completa'}) >> "${logPathW}"`,
            ')',
            `del "${zipPathW}" 2>nul`,
            // Reiniciar el worker SIEMPRE (incluso si la extraccion fallo): es preferible
            // que el worker vuelva a correr y reintente en el proximo ciclo, a dejarlo caido.
            // --hidden: reinicia en la bandeja sin abrir ventana (el afiliado no ve popup).
            `start "" "${exePathW}" --hidden`,
            `del "${ps1Path}" 2>nul`,
            'del "%~f0"',
        ];
        fs.writeFileSync(batPath, batLines.join('\r\n'), 'utf8');

        // ── Launcher VBS: corre el .bat con ventana OCULTA (intWindowStyle = 0).
        // Es la forma fiable en Windows de evitar el "flash" de consola negra que
        // alarmaba a los afiliados. wscript.exe no tiene ventana propia, y el `Run`
        // con estilo 0 oculta por completo la consola del cmd/powershell.
        const batPathW = batPath.replace(/\//g, '\\');
        const vbsPath = path.join(os.tmpdir(), 'xponencia-apply-update.vbs');
        const vbsLines = [
            'Set sh = CreateObject("WScript.Shell")',
            `sh.Run "cmd /c ""${batPathW}""", 0, False`,
        ];
        fs.writeFileSync(vbsPath, vbsLines.join('\r\n'), 'utf8');

        // Marker para "✓ Actualizado a vX.X" en el siguiente arranque.
        // Guardamos la versión real para que el toast la muestre correctamente.
        try { fs.writeFileSync(JUST_UPDATED_PATH, actualRemoteVer || '1', 'utf8'); } catch (_) {}
        // .applied-version: persiste hasta que auto-update-check confirme que
        // version.json fue reparado. Escrito en SCRIPT_DIR (AppData — siempre escribible).
        // Evita el loop aunque APP_DIR/version.json no se pueda actualizar por permisos.
        try { fs.writeFileSync(APPLIED_VER_PATH, actualRemoteVer || '1', 'utf8'); } catch (_) {}
        // Lanzar el updater de forma OCULTA y desacoplada del proceso de Electron.
        // No usamos app.relaunch({ execPath: 'cmd.exe' }) porque abre una consola
        // visible. En su lugar spawneamos wscript (sin ventana) sobre el VBS, que a
        // su vez corre el .bat oculto. El `timeout /t 3` del .bat espera a que
        // Electron termine de cerrar antes de tocar los archivos.
        const updater = spawn('wscript.exe', [vbsPath], {
            detached: true,
            stdio: 'ignore',
            windowsHide: true,
        });
        updater.unref();
        // Notificación del sistema (toast de Windows): a diferencia del aviso en la
        // ventana (renderer), esta se ve incluso si la app corre oculta en la bandeja
        // (--hidden). Da indicación de que el cierre inminente es una actualización
        // planeada, no un crash.
        showSystemNotification('xponencIA Worker', `Actualizando${actualRemoteVer ? ' a v' + actualRemoteVer : ''}… se reinicia sola en unos segundos.`);
        // n.show() en Windows registra el toast de forma async — dar un respiro
        // antes de matar el proceso para que alcance a mostrarse.
        setTimeout(() => app.exit(0), 400);
        return { ok: true, isPatch };
    } else {
        // Para instalador .exe: abrir el instalador y salir
        shell.openPath(zipPath);
        setTimeout(() => app.quit(), 1500);
        return { ok: true };
    }
});

// Devuelve { updated: true, version } si acabamos de aplicar un update, y borra el marker.
// Devuelve { updated: false } si es un arranque normal.
ipcMain.handle('check-just-updated', () => {
    if (!fs.existsSync(JUST_UPDATED_PATH)) return { updated: false };
    let markerContent = '1';
    try { markerContent = fs.readFileSync(JUST_UPDATED_PATH, 'utf8').trim(); } catch (_) {}
    try { fs.unlinkSync(JUST_UPDATED_PATH); } catch (_) {}
    // El marker puede contener la versión real (ej. "2026.5.30-2044") o sólo "1" (legacy).
    const version = (markerContent && markerContent !== '1') ? markerContent : getVersionJson().version;

    // ── Layer 3 / Repair: si version.json no refleja la versión del marker,
    // corregirlo aquí en Node.js. Esto cubre el caso donde el PS1 no pudo escribir
    // (permisos, fallo silencioso, JSON malformado, etc.).
    // Es la capa más confiable: corre en el nuevo exe, sincrónicamente, sin PowerShell.
    if (version && version !== '1' && version !== 'desconocida') {
        try {
            const vPath = path.join(APP_DIR, 'version.json');
            const existing = (() => { try { return JSON.parse(fs.readFileSync(vPath, 'utf8')); } catch (_) { return {}; } })();
            if (existing.version !== version) {
                existing.version = version;
                fs.writeFileSync(vPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
                console.log(`[check-just-updated] Reparado version.json: ${existing.version} → ${version}`);
            }
        } catch (_) {}
    }
    // Notificación del sistema para que el afiliado sepa que actualizó,
    // incluso si la ventana está oculta en la bandeja (caso normal post-update).
    showSystemNotification('xponencIA Worker actualizado', `✓ Versión ${version} instalada correctamente.`);
    return { updated: true, version };
});

// ── Challenge resolution browser ─────────────────────────────────────────────
// Abre el navegador con la sesión de la cuenta baneada/con challenge para que el
// usuario resuelva manualmente el challenge en Instagram.
// Monitorea la URL: cuando ya no esté en una URL de challenge, marca la resolución.
//
// Devuelve { ok: true } cuando la resolución se detecta.
// Devuelve { ok: false, error } si hay algún problema.

let _challengeBrowser = null; // referencia global para cancelación

// mark-review-challenge: marca el challenge como "en revisión" sin abrir el browser.
// El usuario ya mandó el video/selfie/doc pero Instagram lo está procesando.
ipcMain.handle('mark-challenge-review', async (_, { challengeId, tipoVerificacion, serverUrl: overrideUrl } = {}) => {
    if (!challengeId) return { ok: false, error: 'challengeId requerido' };
    const env       = readEnv();
    const serverUrl = (overrideUrl || env.TASK_SERVER_URL || '').replace(/\/$/, '');
    if (!serverUrl) return { ok: false, error: 'Sin URL de servidor' };
    try {
        const r = await fetch(`${serverUrl}/api/challenges/${challengeId}/mark-review`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ tipo_verificacion: tipoVerificacion || null }),
        });
        const data = await r.json().catch(() => ({}));
        return { ok: r.ok, ...data };
    } catch (e) {
        return { ok: false, error: e.message };
    }
});

ipcMain.handle('resolve-challenge', async (_, { challengeId, cuenta, tipoVerificacion, serverUrl: overrideUrl } = {}) => {
    if (!cuenta) return { ok: false, error: 'cuenta requerida' };
    if (_challengeBrowser) return { ok: false, error: 'Ya hay un navegador de resolución abierto — cerralo primero' };

    const env       = readEnv();
    const serverUrl = (overrideUrl || env.TASK_SERVER_URL || '').replace(/\/$/, '');
    // SCRIPT_DIR ya está definido a nivel de módulo — directorio donde viven sessions/

    // Buscar sesión de la cuenta
    const sessionCandidates = [
        path.join(SCRIPT_DIR, 'sessions', cuenta),
        path.join(SCRIPT_DIR, 'sessions_seguidoras', cuenta),
    ];
    let sessionDir = null;
    for (const c of sessionCandidates) {
        if (fs.existsSync(c) && fs.readdirSync(c).some(f => !f.startsWith('.'))) {
            sessionDir = c; break;
        }
    }
    if (!sessionDir) {
        return { ok: false, error: `Sin sesión local para @${cuenta}` };
    }

    // Limpiar locks
    for (const lf of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
        const lp = path.join(sessionDir, lf);
        if (fs.existsSync(lp)) try { fs.unlinkSync(lp); } catch (_) {}
    }

    sendToRenderer('challenge-resolve-status', { status: 'opening', cuenta });

    let context;
    try {
        const { getChromium, applyPlaywrightStealth } = require('../realPcContext');
        const chromium = getChromium();
        const channel  = env.BROWSER_CHANNEL || undefined;
        context = await chromium.launchPersistentContext(sessionDir, {
            headless: false,
            channel,
            args: ['--disable-blink-features=AutomationControlled'],
            viewport: null,
            locale: 'es-AR',
            timezoneId: 'America/Argentina/Buenos_Aires',
        });
        await applyPlaywrightStealth(context);
        _challengeBrowser = context;
    } catch (e) {
        return { ok: false, error: `Error al abrir navegador: ${e.message}` };
    }

    const page = context.pages()[0] || await context.newPage();

    // Navegar a Instagram si no estamos ya ahí
    const currentUrl = page.url();
    if (!currentUrl.includes('instagram.com')) {
        try {
            await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded', timeout: 20000 });
        } catch (e) {
            console.warn(`[resolve-challenge] goto error: ${e.message}`);
        }
    }

    sendToRenderer('challenge-resolve-status', {
        status: 'waiting',
        cuenta,
        message: `Resolvé el challenge para @${cuenta} en la ventana del navegador.\nCuando Instagram te muestre la cuenta normal, esta ventana se cerrará automáticamente.`,
    });

    // Monitorear URL hasta que no sea challenge
    const { detect: detectCh } = require('../challengeDetector');
    const resolved = await new Promise(resolve => {
        let closed = false;
        const cleanup = (result) => {
            if (closed) return;
            closed = true;
            clearInterval(poller);
            resolve(result);
        };

        // Poller de URL cada 2s
        const poller = setInterval(async () => {
            try {
                const url = page.url();
                const det = detectCh(url);
                if (!det.detected && url.includes('instagram.com')) {
                    // Ya no está en challenge — resolución detectada
                    sendToRenderer('challenge-resolve-status', {
                        status: 'resolved',
                        cuenta,
                        message: `✅ Challenge resuelto para @${cuenta}`,
                    });
                    cleanup(true);
                }
            } catch (_) {}
        }, 2000);

        // Si el usuario cierra la ventana manualmente
        context.on('close', () => {
            sendToRenderer('challenge-resolve-status', { status: 'closed', cuenta });
            cleanup(false);
        });
        page.on('close', () => {
            sendToRenderer('challenge-resolve-status', { status: 'closed', cuenta });
            cleanup(false);
        });
    });

    try { await context.close().catch(() => {}); } catch (_) {}
    _challengeBrowser = null;

    if (resolved && challengeId && serverUrl) {
        // Marcar el challenge como resuelto en el servidor + enviar tipo_verificacion
        try {
            await fetch(`${serverUrl}/api/challenges/${challengeId}/resolve`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ cuenta, tipo_verificacion: tipoVerificacion || null }),
            });
        } catch (e) {
            console.warn(`[resolve-challenge] Error al notificar servidor: ${e.message}`);
        }
        sendToRenderer('challenge-resolve-status', {
            status: 'done',
            cuenta,
            message: `✅ @${cuenta} re-habilitada correctamente`,
        });
        return { ok: true, cuenta };
    }

    return { ok: resolved, cuenta };
});

// ── Remote uninstall / deactivation ───────────────────────────────────────────
// Cuando el servidor revoca la licencia de este worker (estado='revocado'),
// el cliente se "desinstala" limpiamente: saca el inicio automático, borra el
// acceso directo del Menú Inicio, para el worker y sale.

async function doUninstall(reason = 'Acceso revocado por el servidor') {
    console.log(`[WORKER-GUI] Desinstalando: ${reason}`);

    // 1. Sacar del inicio automático de Windows
    try { app.setLoginItemSettings({ openAtLogin: false, name: 'xponencIA Worker' }); } catch (_) {}

    // 1b. Sacar la tarea programada watchdog (si no, seguiría relanzando el exe cada
    // 15 min después de revocado, en un loop inútil de license-check fallido).
    try {
        require('child_process').exec(
            'schtasks /delete /f /tn "xponencIA Worker Watchdog"',
            { windowsHide: true, timeout: 15000 },
            () => {}
        );
    } catch (_) {}

    // 2. Borrar acceso directo del Menú Inicio
    try {
        const shortcutPath = path.join(
            app.getPath('appData'),
            'Microsoft', 'Windows', 'Start Menu', 'Programs',
            'xponencIA Worker.lnk'
        );
        if (fs.existsSync(shortcutPath)) fs.unlinkSync(shortcutPath);
    } catch (_) {}

    // 2b. Borrar acceso directo de la carpeta Startup
    try {
        const startupShortcutPath = path.join(
            app.getPath('appData'),
            'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup',
            'xponencIA Worker.lnk'
        );
        if (fs.existsSync(startupShortcutPath)) fs.unlinkSync(startupShortcutPath);
    } catch (_) {}

    // 3. Notificar al renderer (muestra pantalla de revocación)
    sendToRenderer('worker-revoked', { reason });

    // 4. Parar el worker process
    stopWorker();

    // 5. Cerrar la app tras un breve delay para que el usuario vea el mensaje
    setTimeout(() => { isQuitting = true; app.quit(); }, 4000);
}

// Polling de licencia mientras la app está corriendo.
// Si el admin revoca el acceso → desinstalar.
let _licenseWatchTimer = null;
function startLicenseWatch() {
    if (_licenseWatchTimer) return;
    _licenseWatchTimer = setInterval(async () => {
        const env = readEnv();
        const serverUrl = env.TASK_SERVER_URL;
        if (!serverUrl) return;
        const status = await checkLicenseWithServer(getWorkerHwid(), serverUrl).catch(() => null);
        if (status?.estado === 'revocado') {
            clearInterval(_licenseWatchTimer);
            _licenseWatchTimer = null;
            doUninstall('Acceso revocado por el administrador');
        }
    }, 5 * 60 * 1000); // cada 5 minutos
}

// ── Window ────────────────────────────────────────────────────────────────────

// ── Iconos ─────────────────────────────────────────────────────────────────────
// APP_ICON_PATH: versión circular (948×948, alpha) — para BrowserWindow, tray,
//   acceso directo del Menú Inicio y exe (taskbar de Windows).
//   Al ser cuadrada y con fondo transparente queda bien en todos los contextos
//   del sistema operativo (taskbar dark/light, bandeja del sistema, alt-tab).
const APP_ICON_PATH = (() => {
    const candidates = [
        path.join(__dirname, 'icon.png'),                           // worker/gui/icon.png (circular, copiado en build)
        path.join(APP_DIR, 'media', 'xponencia_logo_1 _circular.png'),
        path.join(APP_DIR, 'worker', 'gui', 'icon.png'),
    ];
    return candidates.find(p => fs.existsSync(p)) || null;
})();

function createWindow(hidden = false) {
    mainWindow = new BrowserWindow({
        width: 960,
        height: 720,
        minWidth: 800,
        minHeight: 560,
        backgroundColor: '#000000',
        title: 'xponencIA Worker',
        icon: APP_ICON_PATH || undefined,
        webPreferences: {
            preload: path.join(__dirname, 'preload-worker.js'),
            contextIsolation: true,
            nodeIntegration: false,
        },
        show: false,
    });

    mainWindow.loadFile(path.join(__dirname, 'index.html'));
    mainWindow.once('ready-to-show', () => {
        // Establecer el ícono explícitamente DESPUÉS de ready-to-show.
        // El parámetro `icon` del constructor setea el título, pero en Windows
        // el ícono de la barra de tareas requiere setIcon() explícito después
        // de que la ventana está lista para mostrarse.
        if (APP_ICON_PATH) {
            try {
                mainWindow.setIcon(nativeImage.createFromPath(APP_ICON_PATH));
            } catch (_) {}
        }

        // Si arrancó con Windows (--hidden), quedarse en la bandeja y no abrir ventana.
        // El usuario puede abrirla haciendo doble-click en el ícono de la bandeja.
        if (!hidden) mainWindow.show();
        // Auto-iniciar el worker al abrir la GUI
        startWorker();
        // Telemetría visual: arrancar el poller de captura del panel (ventana GUI).
        startScreenshotPoller();
    });

    mainWindow.on('close', (e) => {
        if (!isQuitting) {
            e.preventDefault();
            mainWindow.hide();
        }
    });
    mainWindow.on('closed', () => { mainWindow = null; });

    Menu.setApplicationMenu(null);
}

function createPopupWindow() {
    if (popupWindow && !popupWindow.isDestroyed()) return popupWindow;
    popupWindow = new BrowserWindow({
        width:           300,
        height:          210,
        frame:           false,
        resizable:       false,
        movable:         false,
        skipTaskbar:     true,
        alwaysOnTop:     true,
        show:            false,
        transparent:     false,
        backgroundColor: '#111111',
        webPreferences: {
            preload:            path.join(__dirname, 'preload-worker.js'),
            contextIsolation:   true,
            nodeIntegration:    false,
        },
    });
    popupWindow.loadFile(path.join(__dirname, 'tray-popup.html'));
    popupWindow.on('blur', () => { if (popupWindow && !popupWindow.isDestroyed()) popupWindow.hide(); });
    popupWindow.on('closed', () => { popupWindow = null; });
    return popupWindow;
}

function createTray() {
    const icon = APP_ICON_PATH
        ? nativeImage.createFromPath(APP_ICON_PATH).resize({ width: 16, height: 16 })
        : nativeImage.createEmpty();

    tray = new Tray(icon);
    tray.setToolTip('xponencIA Worker — corriendo en segundo plano');
    tray.setContextMenu(Menu.buildFromTemplate([
        { label: 'Abrir panel', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } else createWindow(); } },
        { type: 'separator' },
        { label: 'Salir', click: () => { isQuitting = true; app.quit(); } },
    ]));

    tray.on('click', () => {
        const popup = createPopupWindow();
        if (popup.isVisible()) {
            popup.hide();
            return;
        }
        const bounds  = tray.getBounds();
        const display = require('electron').screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y });
        const workArea = display.workArea;
        // Position above the tray icon (taskbar at bottom) or below (taskbar at top)
        const popW = 300, popH = 210;
        let x = Math.round(bounds.x + bounds.width / 2 - popW / 2);
        let y = bounds.y > workArea.height / 2
            ? bounds.y - popH - 4          // taskbar at bottom
            : bounds.y + bounds.height + 4; // taskbar at top
        x = Math.max(workArea.x, Math.min(x, workArea.x + workArea.width  - popW));
        y = Math.max(workArea.y, Math.min(y, workArea.y + workArea.height - popH));
        popup.setPosition(x, y);
        popup.show();
        popup.focus();
    });

    tray.on('double-click', () => {
        if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
        else createWindow();
        if (popupWindow) popupWindow.hide();
    });
}

// ── App lifecycle ─────────────────────────────────────────────────────────────

app.whenReady().then(async () => {
    // ── Garantizar valores clave en .env (por si .env.example no los tenía todos) ──
    // El bloque de SCRIPT_DIR (más arriba) ya copió .env.example si no existía .env.
    // Aquí solo completamos claves que el .env.example podría no tener.
    {
        const existing = readEnv();
        const defaults  = {};
        if (!existing.TASK_SERVER_URL) defaults.TASK_SERVER_URL = DEFAULT_SERVER_URL;
        if (!existing.BROWSER_ENGINE)  defaults.BROWSER_ENGINE  = 'playwright';
        if (!existing.BROWSER_CHANNEL) defaults.BROWSER_CHANNEL = 'chrome';
        if (Object.keys(defaults).length) {
            writeEnv(defaults);
            console.log('[WORKER-GUI] .env completado con defaults:', Object.keys(defaults).join(', '));
        }
    }

    // Nota: la inyección de cookies de Chrome fue eliminada.
    // Cada cuenta usa su propio contexto persistente aislado (sessions/<username>).
    // Los ig_did y device cookies son generados por Instagram de forma natural
    // durante el primer login manual — no se hereda nada del Chrome del usuario.

    const env = readEnv();

    // ── Inicio automático con Windows ─────────────────────────────────────────
    // Siempre activo — el afiliado no puede desactivarlo.
    // Se pasa --hidden para que arranque en la bandeja sin abrir la ventana.
    // Envuelto en try/catch: en PCs corporativas/gestionadas (GPO, EDR) la escritura
    // en el registro puede fallar o ser revertida. Sin el try/catch, una excepción
    // acá abortaba silenciosamente TODA la inicialización posterior (tray, ventana,
    // validación de licencia) porque este bloque corre dentro de un app.whenReady()
    // .then(async () => {...}) sin .catch() — un error no manejado cortaba la promesa.
    if (!process.env.ELECTRON_DEV) {
        try {
            app.setLoginItemSettings({
                openAtLogin: true,
                name:        'xponencIA Worker',
                args:        ['--hidden'],   // arranca silencioso en la bandeja del sistema
            });
            // Verificar que realmente tomó efecto — en Windows, setLoginItemSettings
            // puede fallar silenciosamente sin lanzar excepción (ej. permisos bloqueados
            // por política corporativa) y no hay otra forma de detectarlo salvo releer.
            const current = app.getLoginItemSettings();
            if (!current.openAtLogin) {
                reportWorkerEvent('STARTUP',
                    'No se pudo registrar el inicio automático con Windows (setLoginItemSettings no tomó efecto — ¿política de grupo o antivirus corporativo bloqueando la clave Run?)',
                    'WARNING');
            }
        } catch (e) {
            reportWorkerEvent('STARTUP', `Error al registrar inicio automático con Windows: ${e.message}`, 'WARNING');
        }
    }

    // ── Watchdog de arranque (Tarea Programada) ───────────────────────────────
    // Complementa la clave Run, no la reemplaza. Motivos:
    //   1. Se vio un caso real de la clave Run vacía (probablemente borrada por
    //      antivirus o por la limpieza de "impacto en el inicio" de Windows) mientras
    //      el Administrador de tareas seguía mostrando el ítem como "Habilitado"
    //      (ese flag vive aparte, en StartupApproved, y no refleja si la clave existe).
    //   2. La clave Run solo dispara en logon. Si el proceso muere a mitad de sesión
    //      (crash, kill accidental, Chrome zombie) y el afiliado solo suspende la PC
    //      en vez de reiniciarla, no vuelve a levantar hasta el próximo login real
    //      — que puede tardar semanas.
    // Deliberadamente SIN trigger de logon: probado en vivo (schtasks + XML, usuario
    // sin admin) que registrar un LogonTrigger devuelve "Acceso denegado" — Windows
    // exige privilegios de admin específicamente para ese tipo de trigger; el trigger
    // periódico NO tiene esa restricción y se crea sin problema. La clave Run ya
    // cubre el logon, así que no hace falta. Y el trigger periódico de paso AUTOCURA
    // la clave Run si algo la vuelve a borrar: cuando el watchdog relanza el exe,
    // whenReady() re-registra tanto la clave Run como esta misma tarea.
    // El trigger NO relanza el exe incondicionalmente: primero corre un chequeo
    // Get-Process. En teoría requestSingleInstanceLock() ya debería deduplicar (si hay
    // una instancia viva, la nueva sale sola al toque — ver el log de single-instance
    // más arriba) pero un afiliado reportó la ventana apareciendo en primer plano
    // "cada tanto" después de sumar este watchdog, y persistió incluso filtrando
    // --hidden en el handler de 'second-instance'. En vez de seguir apostando a que
    // Electron dedupe bien en el build empaquetado, el guard evita que el exe se
    // lance siquiera si el proceso ya está vivo — así no importa la causa exacta del
    // problema de fondo, deja de tener oportunidad de dispararse.
    // Se arma vía /XML (no los switches simples de schtasks) para poder fijar:
    //   - ExecutionTimeLimit=PT0S: el default de Task Scheduler mata la tarea a las
    //     72hs de "corriendo" — mataría al Worker cada 3 días si no se pisa.
    //   - DisallowStartIfOnBatteries/StopIfGoingOnBatteries=false: son true por
    //     default, irrelevante en desktop pero rompería el watchdog en afiliados
    //     con laptop.
    // No requiere privilegios de admin (confirmado en vivo). Se re-crea (/F) en cada
    // arranque exitoso para autocurarse si algo la borra.
    if (!process.env.ELECTRON_DEV) {
        try {
            const taskName = 'xponencIA Worker Watchdog';
            const xmlEscape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
            const psSafe = (s) => s.replace(/'/g, "''"); // escape de comilla simple para strings de PowerShell
            const exePath = app.getPath('exe');
            // Nombre de proceso para Get-Process: sin extensión .exe (así es como
            // Windows/PowerShell lo expone). Se calcula del path real, no hardcodeado,
            // por si el nombre del exe cambia en un build futuro.
            const exeBaseName = path.basename(exePath, path.extname(exePath));
            const psInner = `if (-not (Get-Process -Name '${psSafe(exeBaseName)}' -ErrorAction SilentlyContinue)) ` +
                `{ Start-Process -FilePath '${psSafe(exePath)}' -ArgumentList '--hidden' }`;
            // El chequeo corre vía wscript+.vbs (estilo de ventana 0) en vez de
            // powershell.exe directo desde Task Scheduler: -WindowStyle Hidden en
            // powershell NO siempre alcanza a ocultar el flash inicial de la consola
            // — un afiliado reportó justo eso (una ventana tipo terminal que se
            // maximiza y minimiza por un instante cada vez que dispara el watchdog).
            // Este es el MISMO patrón que ya usa el flujo de auto-update más abajo en
            // este archivo (ver el updater de "check-for-updates") porque es la única
            // forma confiable en Windows de no mostrar ni un frame de consola.
            const psScriptPath = path.join(SCRIPT_DIR, '.watchdog-check.ps1');
            fs.writeFileSync(psScriptPath, psInner, 'utf8');
            const vbsEscape = (s) => s.replace(/"/g, '""'); // escape de comilla doble para strings VBS
            const vbsPath = path.join(SCRIPT_DIR, '.watchdog-launcher.vbs');
            const vbsContent = [
                'Set sh = CreateObject("WScript.Shell")',
                `sh.Run "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File ""${vbsEscape(psScriptPath)}""", 0, False`,
            ].join('\r\n');
            fs.writeFileSync(vbsPath, vbsContent, 'utf8');
            const wscriptExe = 'C:\\Windows\\System32\\wscript.exe';
            const watchdogArgs = `"${vbsPath}"`;
            // Ancla del trigger: cualquier instante sirve, Windows calcula la próxima
            // ocurrencia a partir del intervalo — no hace falta que sea "hoy".
            // Hora LOCAL (sin 'Z'): Task Scheduler interpreta un StartBoundary sin
            // offset de timezone como hora local de la máquina.
            const _now = new Date();
            const _pad = (n) => String(n).padStart(2, '0');
            const startBoundary = `${_now.getFullYear()}-${_pad(_now.getMonth() + 1)}-${_pad(_now.getDate())}` +
                `T${_pad(_now.getHours())}:${_pad(_now.getMinutes())}:${_pad(_now.getSeconds())}`;
            const taskXml = [
                // Nota: declarar "UTF-16" acá aunque el archivo se escriba en UTF-8
                // plano (abajo) es intencional — probado en vivo con schtasks.exe:
                // con encoding="UTF-8" real, el parser tira "no se pudo cambiar la
                // codificación"/"formato incorrecto" salvo que se agregue BOM UTF-8,
                // y con BOM tira otro error distinto. Declarando UTF-16 (mismatch con
                // los bytes reales) el parser lo tolera y funciona sin problema.
                '<?xml version="1.0" encoding="UTF-16"?>',
                '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
                '  <Triggers>',
                '    <TimeTrigger>',
                `      <StartBoundary>${startBoundary}</StartBoundary>`,
                '      <Repetition>',
                '        <Interval>PT15M</Interval>',
                '        <StopAtDurationEnd>false</StopAtDurationEnd>',
                '      </Repetition>',
                '      <Enabled>true</Enabled>',
                '    </TimeTrigger>',
                '  </Triggers>',
                '  <Settings>',
                '    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
                '    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
                '    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
                '    <AllowHardTerminate>true</AllowHardTerminate>',
                '    <StartWhenAvailable>true</StartWhenAvailable>',
                '    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>',
                '    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
                '    <Priority>7</Priority>',
                '  </Settings>',
                '  <Actions Context="Author">',
                '    <Exec>',
                `      <Command>${xmlEscape(wscriptExe)}</Command>`,
                `      <Arguments>${xmlEscape(watchdogArgs)}</Arguments>`,
                '    </Exec>',
                '  </Actions>',
                '</Task>',
            ].join('\n');
            const xmlPath = path.join(SCRIPT_DIR, '.watchdog-task.xml');
            fs.writeFileSync(xmlPath, taskXml, 'utf8');
            require('child_process').exec(
                `schtasks /create /f /tn "${taskName}" /xml "${xmlPath}"`,
                { windowsHide: true, timeout: 15000 },
                (err) => {
                    if (err) {
                        reportWorkerEvent('STARTUP', `No se pudo registrar la tarea programada watchdog: ${err.message}`, 'WARNING');
                    }
                }
            );
        } catch (e) {
            reportWorkerEvent('STARTUP', `Error al preparar la tarea programada watchdog: ${e.message}`, 'WARNING');
        }
    }

    // ── Acceso directo en el Menú Inicio (tecla Windows) ──────────────────────
    // Crea/actualiza el .lnk en AppData/Roaming/Microsoft/Windows/Start Menu/Programs/.
    // Se actualiza siempre para reflejar la ruta del exe en caso de que el afiliado
    // haya movido la carpeta del ZIP (portable).
    if (!process.env.ELECTRON_DEV && app.isPackaged) {
        try {
            const startMenuDir = path.join(
                app.getPath('appData'),
                'Microsoft', 'Windows', 'Start Menu', 'Programs'
            );
            fs.mkdirSync(startMenuDir, { recursive: true });
            const shortcutPath = path.join(startMenuDir, 'xponencIA Worker.lnk');
            const exePath = app.getPath('exe');
            const op = fs.existsSync(shortcutPath) ? 'update' : 'create';
            shell.writeShortcutLink(shortcutPath, op, {
                target:      exePath,
                description: 'xponencIA Worker — Automatización Instagram',
                icon:        APP_ICON_PATH || exePath,
                iconIndex:   0,
            });
        } catch (_) { /* no crítico */ }
    }

    // ── Acceso directo en la carpeta Startup (tercera vía de autoarranque) ────
    // Independiente de la clave Run y de la Tarea Programada: es otro subsistema de
    // Windows (carpeta AppData, no registro ni Task Scheduler), así que una política
    // o antivirus que bloquee específicamente uno de los otros dos no necesariamente
    // bloquea este. No requiere privilegios. --hidden para que arranque en bandeja,
    // igual que la clave Run.
    if (!process.env.ELECTRON_DEV && app.isPackaged) {
        try {
            const startupDir = path.join(
                app.getPath('appData'),
                'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'
            );
            fs.mkdirSync(startupDir, { recursive: true });
            const startupShortcutPath = path.join(startupDir, 'xponencIA Worker.lnk');
            const exePathStartup = app.getPath('exe');
            const opStartup = fs.existsSync(startupShortcutPath) ? 'update' : 'create';
            shell.writeShortcutLink(startupShortcutPath, opStartup, {
                target:      exePathStartup,
                args:        '--hidden',
                description: 'xponencIA Worker — Automatización Instagram',
                icon:        APP_ICON_PATH || exePathStartup,
                iconIndex:   0,
            });
        } catch (_) { /* no crítico */ }
    }

    // ── ¿Arrancó desde Windows (doble sesión de usuario) o fue el usuario? ────
    // Electron pasa --hidden cuando Windows lo lanza al inicio de sesión
    // (porque así lo registramos en setLoginItemSettings).
    const startHidden = process.argv.includes('--hidden');

    // Diagnóstico de energía/conectividad (observacional, no cambia config del SO).
    // Registra suspend/resume/lock/battery en worker_logs para correlacionar
    // desconexiones con eventos reales de suspensión.
    setupPowerMonitor();

    createTray();

    // En modo DEBUG se omite la validación de licencia
    if (env.DEBUG_MODE === 'true') {
        createWindow(startHidden);
        startMainHeartbeat();
        return;
    }

    const hwid = getWorkerHwid();
    const serverUrl = env.TASK_SERVER_URL;
    let approved = false;

    if (serverUrl) {
        const status = await checkLicenseWithServer(hwid, serverUrl);
        approved = (status?.estado === 'aprobado');
    }

    if (approved) {
        createWindow(startHidden);
        startLicenseWatch();
        startMainHeartbeat();
    } else {
        // Si arrancó con Windows y ya no está aprobado (raro), mostrar activación igual.
        showActivationWindow(hwid);
    }
});

app.on('second-instance', (_event, commandLine) => {
    // El watchdog (Tarea Programada, cada 15 min) relanza el exe con --hidden para
    // revivirlo si se murió — pero si ya está corriendo, esa segunda instancia muere
    // al toque contra el lock y dispara este evento en la instancia real. Sin este
    // chequeo, mostraba/enfocaba la ventana cada 15 min sin que el afiliado hiciera
    // nada — eso es lo que reportaron como "la ventana aparece maximizada de la nada".
    // Un doble-click real del afiliado (sin --hidden) sigue mostrando la ventana.
    if (commandLine && commandLine.includes('--hidden')) return;
    if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); }
});

app.on('window-all-closed', () => { /* vive en la bandeja */ });

app.on('before-quit', () => {
    isQuitting = true;
    _stopPowerBlock();
    _restorePowerPlan();      // Revertir mitigación B antes de salir
    _restoreNicPowerSave();   // Revertir mitigación C (NIC) antes de salir
    if (licensePoller) { clearInterval(licensePoller); licensePoller = null; }
    if (linkBrowser) { try { linkBrowser.close(); } catch (_) {} }
    if (workerProcess) { try { workerProcess.kill('SIGKILL'); } catch (_) {} }
});
