'use strict';
const { detect: detectChallenge } = require('./challengeDetector');
const { uploadBuffer } = require('./screenshotReporter');

// Versión del "vocabulario" de acciones que este scriptExecutor entiende. El
// servidor (server/scripts/*.js) genera los steps y puede desplegar código nuevo
// con solo reiniciar el proceso; el worker necesita un build + rollout de
// auto-update que tarda en llegar a todos los afiliados. Si el servidor empieza
// a emitir una acción nueva antes de que el worker la soporte, el switch de abajo
// tira "Acción de script desconocida" y la tarea muere (visto en producción con
// `try_group`, 2026-07). Bump este número cada vez que se agrega un `case` nuevo
// y el generador de steps debe chequear la versión reportada por el worker
// (columna `script_protocol_version` en ig_workers) antes de usar la acción nueva.
//   v1 → acciones base (navigate, click, type, extract, conditional, loop, report, ...)
//   v2 → try_group (aislamiento de fallos por lead en requestsScript.js)
const SCRIPT_PROTOCOL_VERSION = 2;

// Error especial que el workerRuntime reporta como CHALLENGE_DETECTED.
class ChallengeError extends Error {
    constructor(detection) {
        super(`CHALLENGE_DETECTED: ${detection.pattern} — ${detection.url}`);
        this.code = 'CHALLENGE_DETECTED';
        this.challengeUrl = detection.url;
        this.challengePattern = detection.pattern;
    }
}

/**
 * Ejecutor genérico de scripts paso a paso.
 * Recibe una página de Playwright y un array de steps.
 * No contiene lógica de negocio: solo sabe ejecutar acciones atómicas.
 *
 * Acciones básicas:
 *   navigate        { url, waitUntil? }
 *   click           { selector, timeout? }
 *   click_any       { selectors: string[], timeout? }        — prueba selectores en orden
 *   click_if_exists { selectors: string[], timeout? }        — como click_any pero nunca lanza error
 *   type            { selector, text, timeout? }
 *   type_human      { selector, text, minDelay?, maxDelay? } — tipeo biométrico: burst+typos+Gaussian
 *   wait_random     { min, max }                             — con micro-jitters de mouse (dead mouse prevention)
 *   scroll          { direction?, px, jitter? }
 *   hover           { selector, timeout? }
 *   hover_if_exists { selector?, selectors?: string[], timeout? } — Gaussian hover sin click; silencioso si no existe
 *   press_key       { key }
 *   check_element   { selector, state?, timeout? }
 *   check_url       { contains }
 *   wait_for_nav    { timeout? }
 *   simulate_blur   { minMs?, maxMs? }                       — simula cambio de pestaña (window blur/focus)
 *   screenshot      { reason?, note? }                       — telemetría compulsiva: captura el viewport
 *                                                              y lo sube al servidor (fire-and-forget)
 *
 * Acciones thin-client (lógica en servidor):
 *   extract        { key, js }               — evalúa JS en la página, guarda en ctx[key]
 *   set            { key, value }            — asigna ctx[key] = value directamente
 *   try_group      { steps, catch? }         — ejecuta steps; si falla captura el error y corre catch (errores críticos se re-lanzan)
 *   conditional    { condition, then, else } — evalúa expresión JS contra ctx, rama then/else
 *   loop           { over, as, steps }       — itera ctx[over], ejecuta steps por elemento
 *   report         { endpoint, keys?, result_key?, timeout? }
 *                                            — POST al servidor con datos de ctx,
 *                                              ejecuta steps devueltos por el servidor
 */
class ScriptExecutor {
    /**
     * @param {import('playwright').Page} page
     * @param {object} options
     * @param {object}  [options.initialCtx]   — contexto de variables inicial
     * @param {string}  [options.serverUrl]    — base URL del servidor (para `report`)
     * @param {string}  [options.workerId]     — ID del worker actual
     * @param {string}  [options.taskId]       — ID de la tarea actual
     * @param {object}  [options.cancelToken]  — { cancelled: boolean } seteado a true para abortar
     */
    constructor(page, options = {}) {
        this.page = page;
        this.ctx = options.initialCtx || {};
        this._serverUrl = (options.serverUrl || process.env.TASK_SERVER_URL || '').replace(/\/$/, '');
        this._workerId = options.workerId || process.env.WORKER_ID || 'unknown';
        this._taskId = options.taskId || null;
        this._cancelToken = options.cancelToken || null;
        // Posición del mouse trackeada para dead-mouse prevention (micro-jitters en wait_random)
        this._mousePos = {
            x: 150 + Math.random() * 600,
            y: 100 + Math.random() * 400,
        };
    }

    /**
     * Click con offset Gaussiano desde el centro del elemento (distribución de Fitts's Law).
     * Actualiza _mousePos. Fallback a page.click() si boundingBox no está disponible.
     * @private
     */
    async _gaussClick(selector, timeout = 5000) {
        const el = await this.page.waitForSelector(selector, { timeout, state: 'visible' });
        await el.scrollIntoViewIfNeeded().catch(() => {});
        const box = await el.boundingBox().catch(() => null);
        if (box && box.width > 4 && box.height > 4) {
            // Box-Muller: dos muestras Gaussianas independientes para X e Y
            const bm = () => {
                const u1 = Math.max(1e-10, Math.random()), u2 = Math.random();
                return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
            };
            // σ = 15% del ancho / 12% del alto; clamp a ±42% para no salir del botón
            const cx = box.x + box.width  / 2 + Math.max(-box.width  * 0.42, Math.min(box.width  * 0.42, bm() * box.width  * 0.15));
            const cy = box.y + box.height / 2 + Math.max(-box.height * 0.42, Math.min(box.height * 0.42, bm() * box.height * 0.12));
            await this.page.mouse.move(cx, cy);
            this._mousePos = { x: cx, y: cy };
            await this.page.waitForTimeout(55 + Math.random() * 95);
            await this.page.mouse.click(cx, cy);
            await this.page.waitForTimeout(300 + Math.random() * 150);
        } else {
            await this.page.click(selector, { timeout });
            await this.page.waitForTimeout(400);
        }
    }

    /**
     * Loguea en la consola del worker Y lo envía al servidor (fire-and-forget),
     * para que aparezca en el feed del panel. Mismo canal que la acción `log`.
     * @private
     */
    _workerLog(level, message) {
        const ICONS = { SUCCESS: '✅', ERROR: '❌', WARNING: '⚠️', INFO: 'ℹ️', DEBUG: '🔍' };
        console.log(`${ICONS[level] || 'ℹ️'} [${this._workerId}] ${message}`);
        if (this._serverUrl) {
            fetch(`${this._serverUrl}/api/worker/log`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ workerId: this._workerId, taskId: this._taskId, level, message }),
            }).catch(() => {});
        }
    }

    async _checkChallenge() {
        // También chequear el flag seteado por el listener de framenavigated
        if (this._challengeDetected) throw new ChallengeError(this._challengeDetected);
        const detection = detectChallenge(this.page.url());
        if (detection.detected) throw new ChallengeError(detection);
    }

    async execute(steps) {
        // ── Detector de ban/suspensión en tiempo real ────────────────────────
        // Instagram puede redirigir a /accounts/disabled/, /accounts/suspended/
        // en CUALQUIER momento (incluso durante wait_random o type_human).
        // El listener de framenavigated captura toda navegación del frame principal,
        // no solo las iniciadas por Playwright — cubre redirecciones del servidor.
        this._challengeDetected = null;
        const onFrameNav = (frame) => {
            try {
                if (frame === this.page.mainFrame()) {
                    const detection = detectChallenge(frame.url());
                    if (detection.detected) {
                        this._challengeDetected = detection;
                    }
                }
            } catch (_) {}
        };
        this.page.on('framenavigated', onFrameNav);

        try {
            let completed = 0;
            for (const step of steps) {
                // Chequear cancelación y challenge antes de cada paso
                if (this._cancelToken?.cancelled) {
                    throw new Error('TASK_CANCELLED: tarea cancelada por el servidor');
                }
                if (this._challengeDetected) throw new ChallengeError(this._challengeDetected);
                await this._runStep(step);
                // También chequear justo después del paso (captura redireccionamientos post-acción)
                if (this._challengeDetected) throw new ChallengeError(this._challengeDetected);
                completed++;
            }
            return { stepsCompleted: completed, totalSteps: steps.length };
        } finally {
            // Siempre limpiar el listener para evitar memory leaks
            try { this.page.off('framenavigated', onFrameNav); } catch (_) {}
            this._challengeDetected = null;
        }
    }

    async _runStep(step) {
        const { action, ...p } = step;

        switch (action) {

            // ── ACCIONES BÁSICAS DE PLAYWRIGHT ──────────────────────────────

            case 'navigate': {
                // Soporta {{key}} en la URL para navegar a URLs dinámicas extraídas del DOM.
                const navUrl = (p.url || '').replace(/\{\{(\w+)\}\}/g, (_, k) => this.ctx[k] || '');
                await this.page.goto(navUrl, {
                    waitUntil: p.waitUntil || 'domcontentloaded',
                    timeout: p.timeout || 30000,
                });
                await this._checkChallenge();
                break;
            }

            case 'click':
                // _gaussClick: offset Gaussiano desde el centro (Fitts's Law)
                await this._gaussClick(p.selector, p.timeout || 10000);
                await this._checkChallenge();
                break;

            case 'click_any': {
                // Prueba cada selector en orden con click Gaussiano
                let clicked = false;
                for (const sel of (p.selectors || [])) {
                    try {
                        await this._gaussClick(sel, p.timeout || 5000);
                        clicked = true;
                        break;
                    } catch (_) { /* continuar con el siguiente */ }
                }
                if (!clicked) throw new Error(`click_any: ningún selector funcionó: ${(p.selectors || []).join(', ')}`);
                await this._checkChallenge();
                break;
            }

            case 'click_if_exists': {
                // Como click_any pero NUNCA lanza error — ignora silenciosamente si ningún selector existe.
                // Útil para elementos opcionales: ring de historia, diálogos de confirmación, botones de cierre.
                const sels = p.selectors || (p.selector ? [p.selector] : []);
                for (const sel of sels) {
                    try {
                        await this._gaussClick(sel, p.timeout || 3000);
                        break;
                    } catch (_) { /* silencioso — el elemento puede no existir */ }
                }
                await this._checkChallenge();
                break;
            }

            case 'type':
                await this.page.fill(p.selector, p.text, { timeout: p.timeout || 10000 });
                break;

            case 'type_human': {
                // ── Gaussian noise helper (Box-Muller transform) ──────────────────
                const gaussNoise = (sigma) => {
                    const u1 = Math.max(1e-10, Math.random()), u2 = Math.random();
                    return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) * sigma;
                };
                // ── QWERTY adjacency map para tipeos erróneos realistas ────────────
                // Solo letras minúsculas; el char a corregir se selecciona al azar de la lista
                const QWERTY_ADJ = {
                    a:'sqwz',  b:'vghn',  c:'xdfv',  d:'serfcx', e:'wrsdf', f:'drtgvc',
                    g:'ftyhbv',h:'gyujnb',i:'uojkl', j:'huikmnb',k:'jiolm', l:'kop',
                    m:'njk',   n:'bhjm',  o:'iklp',  p:'ol',     q:'wa',    r:'edft',
                    s:'awedxz',t:'rfgy',  u:'yhji',  v:'cfgb',   w:'qase',  x:'zsdc',
                    y:'tghu',  z:'asx',
                };

                const el2  = await this.page.waitForSelector(p.selector, { timeout: p.timeout || 10000 });
                const box2 = await el2.boundingBox().catch(() => null);

                // Click con offset Gaussiano en el campo de texto
                if (box2 && box2.width > 4) {
                    const gz1 = gaussNoise(1), gz2 = gaussNoise(1);
                    const cx = box2.x + box2.width  / 2 + Math.max(-box2.width  * 0.42, Math.min(box2.width  * 0.42, gz1 * box2.width  * 0.15));
                    const cy = box2.y + box2.height / 2 + Math.max(-box2.height * 0.42, Math.min(box2.height * 0.42, gz2 * box2.height * 0.12));
                    await this.page.mouse.move(cx, cy);
                    this._mousePos = { x: cx, y: cy };
                    await this.page.waitForTimeout(55 + Math.random() * 90);
                    await this.page.mouse.click(cx, cy);
                } else {
                    await el2.click();
                }
                await this.page.waitForTimeout(75 + Math.random() * 110);

                const text2 = p.text  || '';
                // Fallbacks conservadores: 65-140ms ≈ 43-92 WPM (rango humano razonable).
                // En la práctica siempre se pasan minDelay/maxDelay desde el perfil de cuenta.
                const minD  = p.minDelay ?? 65;
                const maxD  = p.maxDelay ?? 140;
                const avgD  = (minD + maxD) / 2;
                const words = text2.split(' ');

                for (let wi = 0; wi < words.length; wi++) {
                    const word    = words[wi];
                    const isShort = word.length <= 4;
                    const isLong  = word.length >= 8;

                    // Pausa cognitiva antes de palabras largas/complejas (carga del programa motor ~250ms)
                    // El humano "planea" la palabra antes de escribirla
                    if (isLong && wi > 0) {
                        await this.page.waitForTimeout(Math.round(200 + Math.random() * 350));
                    }

                    // Factor de ráfaga: palabras cortas 35% más rápido, largas 20% más lento
                    const burstFactor = isShort ? 0.65 : isLong ? 1.20 : 1.0;

                    // Iterar por CODE POINTS (spread), no por code units.
                    // Emojis como 😂 son surrogate pairs en JS (length=2).
                    // word[0]=\uD83D + word[1]=\uDE02 → keyboard.type de cada mitad suelta → emoji roto.
                    // [...word] itera por código Unicode completo → 😂 es un solo char.
                    const chars = [...word];
                    for (let ci = 0; ci < chars.length; ci++) {
                        const char  = chars[ci];
                        const lower = char.toLowerCase();

                        // Inyección de typo: ~1 cada 150 chars. Evitar primer/último char de palabra.
                        // Solo en caracteres ASCII básicos (no en emojis ni acentuados)
                        if (ci > 0 && ci < chars.length - 1 && char.length === 1 && Math.random() < (1 / 150)) {
                            const adj = QWERTY_ADJ[lower];
                            if (adj) {
                                const typoChar = adj[Math.floor(Math.random() * adj.length)];
                                await this.page.keyboard.type(typoChar, { delay: 0 });
                                // Tiempo de reacción: el humano "nota" el error (150-400ms)
                                await this.page.waitForTimeout(Math.round(155 + Math.random() * 245));
                                await this.page.keyboard.press('Backspace');
                                await this.page.waitForTimeout(Math.round(45  + Math.random() * 75));
                            }
                        }

                        // Delay por carácter: muestra Gaussiana alrededor del base uniforme, escalada por burst
                        const charDelay = Math.max(12, Math.round(
                            (minD + Math.random() * (maxD - minD) + gaussNoise((maxD - minD) * 0.12)) * burstFactor
                        ));
                        await this.page.keyboard.type(char, { delay: charDelay });
                    }

                    // Espacio + pausa inter-palabra (no después de la última)
                    if (wi < words.length - 1) {
                        await this.page.keyboard.type(' ', { delay: 0 });
                        // Pausa inter-palabra: 1.8–3.2× el delay promedio de char (efecto de ráfaga)
                        const wordPause = Math.round(avgD * (1.8 + Math.random() * 1.4));
                        await this.page.waitForTimeout(wordPause);
                    }
                }
                break;
            }

            case 'wait_random': {
                const ms = p.min + Math.floor(Math.random() * (p.max - p.min));

                // Dead mouse prevention: para esperas > 1500ms inyectar micro-movimientos del mouse.
                // Un ratón completamente estático durante >10s es señal clásica de bot.
                // Los segmentos de ~850ms permiten micro-jitters sin interrumpir el flujo.
                if (ms > 1500) {
                    const segments = Math.floor(ms / 850);
                    let elapsed = 0;
                    for (let i = 0; i < segments && elapsed < ms - 400; i++) {
                        if (this._cancelToken?.cancelled) throw new Error('TASK_CANCELLED: tarea cancelada durante wait_random');
                        const wait = Math.round(750 + Math.random() * 200);
                        await this.page.waitForTimeout(wait);
                        elapsed += wait;
                        // Micro-jitter: 3–14px en dirección aleatoria (temblor inconsciente de mano)
                        const nx = Math.max(20, Math.min(1380, this._mousePos.x + (Math.random() - 0.5) * 18));
                        const ny = Math.max(20, Math.min(880,  this._mousePos.y + (Math.random() - 0.5) * 18));
                        await this.page.mouse.move(nx, ny).catch(() => {});
                        this._mousePos = { x: nx, y: ny };
                    }
                    if (this._cancelToken?.cancelled) throw new Error('TASK_CANCELLED: tarea cancelada durante wait_random');
                    const remaining = ms - elapsed;
                    if (remaining > 50) await this.page.waitForTimeout(remaining);
                } else {
                    await this.page.waitForTimeout(ms);
                }
                break;
            }

            case 'simulate_blur': {
                // Simula que el usuario cambia brevemente a otra pestaña (Spotify, WhatsApp Web, etc.)
                // SOLO dispara eventos window.blur/focus — NO modifica document.visibilityState
                // para evitar romper handlers React de Instagram.
                // Señal que buscan los detectores: visibilityState 'visible' al 100% del tiempo = bot.
                const blurMinMs = p.minMs ?? 3000;
                const blurMaxMs = p.maxMs ?? 10000;
                const blurMs = Math.round(blurMinMs + Math.random() * (blurMaxMs - blurMinMs));
                await this.page.evaluate(() => window.dispatchEvent(new Event('blur'))).catch(() => {});
                await this.page.waitForTimeout(blurMs);
                await this.page.evaluate(() => window.dispatchEvent(new Event('focus'))).catch(() => {});
                break;
            }

            case 'scroll': {
                const base = p.px ?? 300;
                const jitter = p.jitter ? Math.floor(Math.random() * 80) - 40 : 0;
                const direction = p.direction === 'up' ? -1 : 1;
                await this.page.evaluate(
                    ([scrollPx]) => window.scrollBy(0, scrollPx),
                    [(base + jitter) * direction]
                );
                break;
            }

            case 'hover':
                await this.page.hover(p.selector, { timeout: p.timeout || 10000 });
                break;

            case 'hover_if_exists': {
                // Mueve el mouse sobre el elemento con offset Gaussiano (sin click).
                // Nunca lanza error — silencioso si el selector no existe.
                // Imita el comportamiento humano de "posar" el cursor sobre contenido
                // antes de decidir si hacer click (dead-zone prevention semántica).
                const hoverSels = p.selectors || (p.selector ? [p.selector] : []);
                for (const hSel of hoverSels) {
                    try {
                        const hEl = await this.page.waitForSelector(hSel, { timeout: p.timeout || 2000, state: 'visible' });
                        await hEl.scrollIntoViewIfNeeded().catch(() => {});
                        const hBox = await hEl.boundingBox().catch(() => null);
                        if (hBox && hBox.width > 4 && hBox.height > 4) {
                            const bm = () => {
                                const u1 = Math.max(1e-10, Math.random()), u2 = Math.random();
                                return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
                            };
                            const hcx = hBox.x + hBox.width  / 2 + Math.max(-hBox.width  * 0.42, Math.min(hBox.width  * 0.42, bm() * hBox.width  * 0.15));
                            const hcy = hBox.y + hBox.height / 2 + Math.max(-hBox.height * 0.42, Math.min(hBox.height * 0.42, bm() * hBox.height * 0.12));
                            await this.page.mouse.move(hcx, hcy);
                            this._mousePos = { x: hcx, y: hcy };
                            // Breve pausa: el usuario "lee" el elemento antes de decidir
                            await this.page.waitForTimeout(180 + Math.random() * 380);
                        } else {
                            await this.page.hover(hSel).catch(() => {});
                        }
                        break; // Primer selector encontrado — no intentar más
                    } catch (_) { /* elemento no visible/encontrado — silencioso */ }
                }
                break;
            }

            case 'press_key':
                await this.page.keyboard.press(p.key);
                break;

            case 'check_element':
                await this.page.waitForSelector(p.selector, {
                    state: p.state || 'visible',
                    timeout: p.timeout || 5000,
                });
                break;

            case 'check_url':
                await this._checkChallenge();
                if (p.contains && !this.page.url().includes(p.contains)) {
                    throw new Error(`URL inesperada: se esperaba "${p.contains}" en "${this.page.url()}"`);
                }
                break;

            case 'wait_for_nav':
                await this.page.waitForNavigation({ timeout: p.timeout || 10000 });
                await this._checkChallenge();
                break;

            // ── ACCIONES THIN-CLIENT ─────────────────────────────────────────

            case 'extract': {
                // Ejecuta p.js como expresión/IIFE en el contexto del navegador,
                // guarda el resultado en ctx[key].
                // p.js debe ser una expresión que retorne un valor serializable.
                const result = await this.page.evaluate(p.js);
                if (p.key) this.ctx[p.key] = result;
                break;
            }

            case 'set':
                // Asigna un valor constante en el contexto (útil para pasar datos del servidor)
                this.ctx[p.key] = p.value;
                break;

            case 'try_group': {
                // Ejecuta p.steps como un bloque aislado de fallos.
                // Si cualquier step lanza, se ejecutan p.catch (si los hay) y se continúa.
                // Errores críticos (CHALLENGE_DETECTED, TASK_CANCELLED, BROWSER_CLOSED)
                // siempre se re-lanzan: no tiene sentido seguir si la sesión murió.
                try {
                    for (const s of (p.steps || [])) {
                        if (this._cancelToken?.cancelled) throw new Error('TASK_CANCELLED: tarea cancelada por el servidor');
                        if (this._challengeDetected) throw new ChallengeError(this._challengeDetected);
                        await this._runStep(s);
                        if (this._challengeDetected) throw new ChallengeError(this._challengeDetected);
                    }
                } catch (err) {
                    const msg = err?.message || '';
                    // Fatal de verdad (challenge, cancelación, o el browser/contexto murió
                    // -típico de un sleep de Windows a mitad de tarea-): re-lanzar, seguir
                    // con el próximo lead sería inútil, todos fallarían igual.
                    if (err.code === 'CHALLENGE_DETECTED'
                        || /TASK_CANCELLED|BROWSER_CLOSED/.test(msg)
                        || /Target (page|frame|context) .*closed|browser has been closed|Execution context was destroyed/i.test(msg)) {
                        throw err;
                    }
                    // Fallo puntual (selector no encontrado, timeout aislado, etc.): loguear
                    // el motivo real en ctx._tryError para que p.catch pueda reportarlo, y
                    // seguir con el resto de la sesión en vez de perderla entera.
                    this.ctx._tryError = msg || String(err);
                    for (const s of (p.catch || [])) {
                        await this._runStep(s).catch(() => {});
                    }
                }
                break;
            }

            case 'conditional': {
                // Evalúa p.condition como expresión JS con acceso a ctx,
                // ejecuta p.then o p.else según el resultado.
                const condFn = new Function('ctx', `return !!(${p.condition})`);
                const branch = condFn(this.ctx) ? (p.then || []) : (p.else || []);
                for (const s of branch) await this._runStep(s);
                break;
            }

            case 'loop': {
                // Itera sobre ctx[over], asigna cada elemento a ctx[as],
                // y ejecuta los steps por cada elemento.
                const items = this.ctx[p.over];
                if (!Array.isArray(items)) break;
                for (const item of items) {
                    this.ctx[p.as] = item;
                    for (const s of p.steps) await this._runStep(s);
                }
                break;
            }

            case 'report': {
                // Envía datos de ctx al servidor y ejecuta los steps devueltos.
                // skip_on_error: true → si el reporte falla (red, timeout, 5xx), solo
                // loguea el error y continúa en lugar de matar todo el task.
                // Usado en scans de monitoreo: un fallo puntual no debe cortar la sesión.
                if (!this._serverUrl) throw new Error('report action: TASK_SERVER_URL no configurado');

                const keys = p.keys || Object.keys(this.ctx);
                const data = Object.fromEntries(keys.map(k => [k, this.ctx[k]]));

                const controller = new AbortController();
                const timer = setTimeout(() => controller.abort(), p.timeout || 30000);
                // También abortar si el cancel token se activa (ej: servidor canceló la tarea)
                // Sin esto, el worker queda bloqueado en el fetch de 45s sin poder cancelarse
                const cancelCheck = setInterval(() => {
                    if (this._cancelToken?.cancelled) controller.abort();
                }, 1000);

                let resp;
                try {
                    const res = await fetch(`${this._serverUrl}${p.endpoint}`, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ taskId: this._taskId, workerId: this._workerId, data }),
                        signal: controller.signal,
                    });
                    if (!res.ok) {
                        const txt = await res.text().catch(() => '');
                        throw new Error(`report HTTP ${res.status}: ${txt.slice(0, 200)}`);
                    }
                    resp = await res.json();
                } catch (err) {
                    clearTimeout(timer);
                    clearInterval(cancelCheck);
                    if (p.skip_on_error) {
                        console.warn(`[REPORT] ${p.endpoint} falló (skip_on_error): ${err.message}`);
                        break;
                    }
                    throw err;
                } finally {
                    clearTimeout(timer);
                    clearInterval(cancelCheck);
                }

                if (p.result_key && resp.result !== undefined) {
                    this.ctx[p.result_key] = resp.result;
                }

                // Ejecutar los steps devueltos por el servidor (si los hay)
                if (Array.isArray(resp.steps) && resp.steps.length) {
                    for (const s of resp.steps) await this._runStep(s);
                }
                break;
            }

            case 'log': {
                // Loguea en consola del worker y envía al servidor (fire-and-forget).
                // Soporta interpolación de ctx: "url={{current_url}}" → "url=https://..."
                const ICONS = {
                    SUCCESS: '✅', ERROR: '❌', WARNING: '⚠️',
                    BROWSER: '🌐', HUMAN: '🤖', GEMINI: '✨', DEBUG: '🔍', INFO: 'ℹ️',
                };
                const icon = ICONS[p.level] || 'ℹ️';
                // Interpolar {{key}} con valores del ctx
                const rawMsg = p.message || '';
                const msg = rawMsg.replace(/\{\{(\w+)\}\}/g, (_, k) => {
                    const v = this.ctx[k];
                    return v === undefined ? `{{${k}}}` : (typeof v === 'object' ? JSON.stringify(v) : String(v));
                });
                console.log(`${icon} [${this._workerId}] ${msg}`);
                if (this._serverUrl) {
                    fetch(`${this._serverUrl}/api/worker/log`, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            workerId: this._workerId,
                            taskId: this._taskId,
                            level: p.level || 'INFO',
                            message: msg,
                        }),
                    }).catch(() => {}); // fire-and-forget, nunca bloquear ejecución
                }
                break;
            }

            case 'screenshot': {
                // Telemetría compulsiva: captura el viewport y lo sube al servidor.
                // - La captura se AWAITea (es rápida: garantiza que tomamos el frame correcto,
                //   antes de que el próximo step navegue o cierre la conversación).
                // - La subida es fire-and-forget: nunca debe bloquear ni demorar el flujo real.
                // - Loguea siempre el resultado al servidor (capturado/subido/falló) para poder
                //   debuggear: antes era una caja negra silenciosa.
                const reason = p.reason || 'on_demand';
                let buf = null;
                try {
                    buf = await this.page.screenshot({ type: 'jpeg', quality: 60 });
                } catch (e) {
                    this._workerLog('WARNING', `📸 screenshot (${reason}) NO se pudo capturar: ${e.message}`);
                    break;
                }
                if (!buf || !buf.length) {
                    this._workerLog('WARNING', `📸 screenshot (${reason}) capturó un buffer vacío`);
                    break;
                }
                // serverUrl explícito (no depender solo de process.env): si falta, uploadBuffer lo dice.
                uploadBuffer(buf, {
                    target:    'chrome',
                    reason,
                    workerId:  this._workerId,
                    taskId:    this._taskId,
                    serverUrl: this._serverUrl,
                    note:      p.note || null,
                }).then((r) => {
                    if (r && r.ok) {
                        this._workerLog('INFO', `📸 screenshot (${reason}) subido OK → ${r.url || '(sin url)'}`);
                    } else {
                        this._workerLog('WARNING', `📸 screenshot (${reason}) FALLÓ la subida: ${(r && r.error) || 'desconocido'}`);
                    }
                }).catch((e) => {
                    this._workerLog('WARNING', `📸 screenshot (${reason}) excepción al subir: ${e.message}`);
                });
                break;
            }

            default:
                throw new Error(`Acción de script desconocida: "${action}"`);
        }
    }
}

module.exports = { ScriptExecutor, ChallengeError, SCRIPT_PROTOCOL_VERSION };
