'use strict';

/**
 * worker/gui/updateDownloader.js
 *
 * Descarga de releases con reanudación (HTTP Range) y reintentos automáticos.
 * Módulo Node puro (sin Electron) para poder testearlo standalone — lo consume
 * el handler IPC 'auto-update-download' de main-worker.js.
 *
 * Por qué existe: los afiliados con conexión inestable (p. ej. Cuba/ETECSA) no
 * logran bajar el ZIP completo (~140 MB) en una sesión TCP continua. Antes,
 * cualquier stall >60s abortaba la descarga y el próximo intento arrancaba
 * desde el byte 0 — en la práctica nunca terminaba. Con este módulo:
 *
 *   · El parcial descargado se conserva en disco junto a un sidecar .meta.json
 *     (URL, versión, tamaño total, Last-Modified del servidor).
 *   · Cada reintento continúa donde quedó vía `Range: bytes=N-`, validando con
 *     `If-Range` que el archivo del servidor no haya cambiado.
 *   · El progreso se acumula incluso entre ciclos de update (cada 30 min):
 *     aunque un ciclo entero falle, el siguiente arranca desde el parcial.
 *   · Si el servidor no soporta Range (versión vieja) responde 200 y se
 *     reinicia desde cero — el comportamiento anterior, sin romper nada.
 *   · Errores HTTP definitivos (404, 500) cortan los reintentos (fatal: true)
 *     para que el caller decida el fallback (p. ej. patch → ZIP completo).
 */

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

const DEFAULTS = {
    connectTimeoutMs: 120000,           // hasta recibir headers de respuesta
    idleTimeoutMs:    180000,           // sin bytes durante la transferencia → abortar intento
    maxAttempts:      4,                // intentos por llamada (el parcial persiste entre llamadas)
    retryDelaysMs:    [5000, 15000, 30000],
};

function _readMeta(metaFile) {
    try { return JSON.parse(fs.readFileSync(metaFile, 'utf8')); } catch (_) { return null; }
}
function _writeMeta(metaFile, meta) {
    try { fs.writeFileSync(metaFile, JSON.stringify(meta), 'utf8'); } catch (_) {}
}
function _sizeOf(file) {
    try { return fs.statSync(file).size; } catch (_) { return 0; }
}
function _rm(file) {
    try { fs.unlinkSync(file); } catch (_) {}
}

/** Un intento de descarga. Conserva el parcial en disco si se corta. */
function _attempt({ url, destFile, metaFile, remoteVer, connectTimeoutMs, idleTimeoutMs, onProgress, log }) {
    return new Promise((resolve) => {
        // ¿Se puede reanudar? Solo si el meta corresponde a esta misma URL y
        // versión, y tenemos el Last-Modified original para validar con If-Range.
        const meta = _readMeta(metaFile);
        const partialSize = _sizeOf(destFile);
        let startAt = 0;
        if (meta && meta.url === url && meta.lastModified && meta.total > 0
            && String(meta.remoteVer || '') === String(remoteVer || '')
            && partialSize > 0 && partialSize < meta.total) {
            startAt = partialSize;
        }

        const headers = {};
        if (startAt > 0) {
            headers['Range']    = `bytes=${startAt}-`;
            headers['If-Range'] = meta.lastModified;
        }

        let out     = null;
        let settled = false;
        const done = (result) => {
            if (settled) return;
            settled = true;
            if (out && !out.destroyed) out.destroy();
            resolve(result);
        };

        const mod = url.startsWith('https') ? https : http;
        const req = mod.get(url, { headers, timeout: connectTimeoutMs }, (res) => {
            const status = res.statusCode;

            if (status === 416) {
                // El parcial ya no es válido (el archivo cambió, u offset fuera de
                // rango): descartarlo para que el próximo intento arranque de cero.
                _rm(destFile); _rm(metaFile);
                res.resume();
                return done({ ok: false, error: 'range_416' });
            }
            if (status !== 200 && status !== 206) {
                res.resume();
                return done({ ok: false, error: `HTTP ${status}`, fatal: true });
            }

            let offset = 0;
            let total  = 0;
            if (status === 206) {
                const cr = /^bytes (\d+)-\d+\/(\d+)$/.exec(res.headers['content-range'] || '');
                if (!cr || parseInt(cr[1], 10) !== startAt) {
                    // 206 con un offset que no pedimos: no hay forma segura de
                    // continuar — descartar el parcial y reintentar de cero.
                    _rm(destFile); _rm(metaFile);
                    res.destroy();
                    return done({ ok: false, error: 'content_range_invalido' });
                }
                offset = startAt;
                total  = parseInt(cr[2], 10);
                if (log) log(`reanudando desde ${(offset / 1048576).toFixed(1)} MB / ${(total / 1048576).toFixed(1)} MB`);
            } else {
                // 200: descarga completa desde cero (primer intento, servidor sin
                // soporte Range, o el archivo cambió y el If-Range lo detectó).
                total = parseInt(res.headers['content-length'] || '0', 10);
            }

            // Guardar el meta ANTES de transferir: si este intento se corta,
            // el próximo puede reanudar. Sin total o sin Last-Modified no hay
            // reanudación confiable — se borra el meta para forzar restart.
            if (total > 0 && res.headers['last-modified']) {
                _writeMeta(metaFile, {
                    url,
                    remoteVer:    remoteVer || '',
                    total,
                    lastModified: res.headers['last-modified'],
                });
            } else {
                _rm(metaFile);
            }

            // Durante la transferencia el stall tolerado es idleTimeoutMs (el
            // socket timeout es de inactividad: cada chunk recibido lo reinicia).
            req.setTimeout(idleTimeoutMs);

            out = fs.createWriteStream(destFile, offset > 0 ? { flags: 'r+', start: offset } : {});
            let received = 0;
            res.on('data', (chunk) => {
                received += chunk.length;
                if (total > 0 && onProgress) {
                    onProgress({
                        pct:      Math.round((offset + received) / total * 100),
                        received: offset + received,
                        total,
                    });
                }
            });
            // Corte abrupto del socket a mitad de transferencia: el error llega
            // por `res`, NO por `req` (una vez recibidos los headers, req ya no
            // emite 'error'). Sin estos handlers el intento quedaría colgado
            // para siempre — el socket destruido tampoco dispara el timeout.
            res.on('error', (e) => done({ ok: false, error: e.message || 'connection_cut' }));
            res.on('close', () => {
                if (!res.complete) done({ ok: false, error: 'connection_cut' });
            });

            res.pipe(out);
            out.on('finish', () => {
                const finalSize = _sizeOf(destFile);
                if (total > 0 && finalSize !== total) {
                    // Stream cerrado prolijo pero incompleto — conservar parcial.
                    return done({ ok: false, error: `incompleta_${finalSize}_de_${total}` });
                }
                _rm(metaFile);
                done({ ok: true, path: destFile });
            });
            out.on('error', (e) => done({ ok: false, error: e.message }));
        });

        // Corte de red / stall / DNS: el parcial y el meta quedan en disco
        // para que el próximo intento (o el próximo ciclo de 30 min) reanude.
        req.on('error',   (e) => done({ ok: false, error: e.message || 'network_error' }));
        req.on('timeout', ()  => req.destroy(new Error(startAt > 0 || out ? 'idle_timeout' : 'timeout')));
    });
}

/**
 * Descarga `url` a `destFile` con reanudación y reintentos.
 *
 * @param {object} opts
 * @param {string}   opts.url         URL completa del release
 * @param {string}   opts.destFile    Path destino (el parcial vive ahí mismo)
 * @param {string}   opts.metaFile    Path del sidecar de reanudación (.meta.json)
 * @param {string?}  opts.remoteVer   Versión remota — invalida parciales de versiones viejas
 * @param {function} [opts.onProgress] ({ pct, received, total })
 * @param {function} [opts.log]        (msg) logging opcional
 * @returns {Promise<{ok: boolean, path?: string, error?: string, fatal?: boolean}>}
 */
async function downloadRelease(opts) {
    const o = { ...DEFAULTS, ...opts };
    let lastError = 'desconocido';
    for (let i = 0; i < o.maxAttempts; i++) {
        if (i > 0) {
            const delay = o.retryDelaysMs[Math.min(i - 1, o.retryDelaysMs.length - 1)];
            if (o.log) o.log(`reintento ${i + 1}/${o.maxAttempts} en ${Math.round(delay / 1000)}s (error: ${lastError})`);
            await new Promise(r => setTimeout(r, delay));
        }
        const r = await _attempt(o);
        if (r.ok) return r;
        lastError = r.error || 'desconocido';
        if (r.fatal) return { ok: false, error: lastError, fatal: true };
    }
    return { ok: false, error: lastError };
}

module.exports = { downloadRelease, DEFAULTS };
