/**
 * Sing-box Process Manager
 * Handles starting, stopping, and monitoring the sing-box VPN core.
 * Locations are stored persistently in locations.json (user-imported).
 *
 * Failure model (see vless-parser.js): the core fails CLOSED. This manager
 * NEVER reacts to "no traffic" by rerouting or auto-disconnecting — if the
 * server is down the tunnel simply blackholes traffic and the user decides
 * what to do. The only automatic teardown is on an explicit user action or
 * when the core process itself dies.
 */

const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const net = require('net');
const http = require('http');
const { parseVlessUrl, generateSingboxConfig } = require('./vless-parser');

class SingboxManager {
    constructor(corePath, userDataPath) {
        this.corePath = corePath;
        this.userDataPath = userDataPath;
        this.locationsFile = path.join(userDataPath, 'locations.json');
        this.configPath = path.join(userDataPath, 'singbox-config.json');
        this.pidFile = path.join(userDataPath, 'singbox.pid');
        this.singboxProcess = null;
        this.isConnected = false;
        this.currentLocation = null;
        this.clashPort = null;
        this.trafficCallback = null;
        this.trafficInterval = null;
        this.logCallback = null;
        this.statusCallback = null;
        this.locationsChangedCallback = null;

        // Traffic delta tracking (computed from cumulative counters)
        this._prevDown = null;
        this._prevUp = null;
        this._prevT = null;

        // Ensure userData directory exists
        if (!fs.existsSync(userDataPath)) {
            fs.mkdirSync(userDataPath, { recursive: true });
        }
    }

    // ============ Location Storage ============

    getLocations() {
        try {
            if (fs.existsSync(this.locationsFile)) {
                const data = fs.readFileSync(this.locationsFile, 'utf8');
                return JSON.parse(data);
            }
        } catch (error) {
            console.error('Failed to load locations:', error);
        }
        return [];
    }

    _saveLocations(locations) {
        try {
            fs.writeFileSync(this.locationsFile, JSON.stringify(locations, null, 2), 'utf8');
        } catch (error) {
            console.error('Failed to save locations:', error);
        }
    }

    /**
     * Add a location from a vless:// URL
     * Returns { success, location?, error? }
     */
    addLocation(vlessUrl) {
        const trimmed = vlessUrl.trim();

        if (!trimmed.toLowerCase().startsWith('vless://')) {
            return { success: false, error: 'Invalid link. Must start with vless://' };
        }

        const parsed = parseVlessUrl(trimmed);
        if (!parsed) {
            return { success: false, error: 'Failed to parse VLESS link' };
        }

        const locations = this.getLocations();

        // Duplicate check: same uuid + server
        const duplicate = locations.some(
            loc => loc.uuid === parsed.uuid && loc.address === parsed.server
        );
        if (duplicate) {
            return { success: false, error: 'This server is already imported' };
        }

        // Generate unique ID
        const id = this._generateId();

        const location = {
            id,
            name: parsed.name || 'Unknown Server',
            address: parsed.server,
            port: parsed.port,
            uuid: parsed.uuid,
            security: parsed.security || 'none',
            type: parsed.type || 'tcp',
            sni: parsed.sni || '',
            fingerprint: parsed.fingerprint || 'chrome',
            publicKey: parsed.publicKey || '',
            shortId: parsed.shortId || '',
            spiderX: parsed.spiderX || '/',
            flow: parsed.flow || '',
            vlessUrl: trimmed,
            addedAt: new Date().toISOString()
        };

        locations.push(location);
        this._saveLocations(locations);
        this._notifyLocationsChanged();

        return { success: true, location };
    }

    /**
     * Remove a location by ID
     * Returns { success, error? }
     */
    removeLocation(locationId) {
        const locations = this.getLocations();
        const index = locations.findIndex(loc => loc.id === locationId);

        if (index === -1) {
            return { success: false, error: 'Location not found' };
        }

        locations.splice(index, 1);
        this._saveLocations(locations);
        this._notifyLocationsChanged();

        return { success: true };
    }

    _generateId() {
        // Simple UUID v4 generator
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
            const r = Math.random() * 16 | 0;
            const v = c === 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }

    _notifyLocationsChanged() {
        if (this.locationsChangedCallback) {
            this.locationsChangedCallback(this.getLocations());
        }
    }

    onLocationsChanged(callback) {
        this.locationsChangedCallback = callback;
    }

    // ============ Process / port helpers ============

    /** Find a free TCP port on the loopback interface for the Clash API. */
    _findFreePort() {
        return new Promise((resolve, reject) => {
            const srv = net.createServer();
            srv.once('error', reject);
            srv.listen(0, '127.0.0.1', () => {
                const port = srv.address().port;
                srv.close(() => resolve(port));
            });
        });
    }

    /** Minimal loopback HTTP GET used for readiness probing and stats. */
    _httpGet(port, reqPath, timeoutMs) {
        return new Promise((resolve, reject) => {
            const req = http.get(
                { hostname: '127.0.0.1', port, path: reqPath, timeout: timeoutMs },
                (res) => {
                    let body = '';
                    res.on('data', (c) => { body += c; });
                    res.on('end', () => resolve({
                        ok: res.statusCode >= 200 && res.statusCode < 300,
                        status: res.statusCode,
                        body,
                    }));
                }
            );
            req.on('error', reject);
            req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
        });
    }

    _writePidFile(pid) {
        try { fs.writeFileSync(this.pidFile, String(pid)); } catch (e) { /* ignore */ }
    }

    _clearPidFile() {
        try { if (fs.existsSync(this.pidFile)) fs.unlinkSync(this.pidFile); } catch (e) { /* ignore */ }
    }

    /**
     * Reap an orphaned sing-box left behind by a previous crash. Targeted by
     * PID file and verified by image name to avoid killing unrelated processes
     * (and to be safe against PID reuse). Call once at startup.
     */
    reapOrphan() {
        try {
            if (!fs.existsSync(this.pidFile)) return;
            const pid = parseInt(fs.readFileSync(this.pidFile, 'utf8').trim(), 10);
            if (pid > 0) {
                const out = execSync(
                    `tasklist /FI "PID eq ${pid}" /FI "IMAGENAME eq sing-box.exe" /NH`,
                    { windowsHide: true }
                ).toString();
                if (out.toLowerCase().includes('sing-box.exe')) {
                    execSync(`taskkill /F /PID ${pid}`, { windowsHide: true, stdio: 'ignore' });
                    this._log(`Reaped orphaned core process (pid ${pid})`);
                }
            }
        } catch (e) {
            // No orphan, or it already exited — nothing to do.
        }
        this._clearPidFile();
    }

    // ============ Connection Management ============

    async connect(locationId) {
        if (this.isConnected) {
            await this.disconnect();
        }

        const locations = this.getLocations();
        const location = locations.find(loc => loc.id === locationId);

        if (!location) {
            throw new Error(`Location ${locationId} not found`);
        }

        // Parse VLESS URL and generate sing-box config
        const vlessConfig = parseVlessUrl(location.vlessUrl);
        if (!vlessConfig) {
            throw new Error('Failed to parse VLESS URL');
        }

        // Pick a free Clash API port so we never collide with port 9090 in use.
        const clashPort = await this._findFreePort();
        this.clashPort = clashPort;

        const singboxConfig = generateSingboxConfig(vlessConfig, { clashPort });

        // Write runtime config to userData (keeps secrets out of the install dir).
        fs.writeFileSync(this.configPath, JSON.stringify(singboxConfig, null, 2));

        const singboxExe = path.join(this.corePath, 'sing-box.exe');

        return new Promise((resolve, reject) => {
            const proc = spawn(singboxExe, ['run', '-c', this.configPath], {
                cwd: this.corePath,
                windowsHide: true,
            });
            this.singboxProcess = proc;
            this._writePidFile(proc.pid);

            let settled = false;
            let lastError = '';

            proc.stdout.on('data', (data) => {
                this._log(data.toString());
            });

            proc.stderr.on('data', (data) => {
                const output = data.toString();
                this._log(output);
                const t = output.trim();
                if (t) lastError = t;
            });

            proc.on('error', (error) => {
                this._log(`Process error: ${error.message}`);
                if (!settled) {
                    settled = true;
                    this._clearPidFile();
                    reject(error);
                }
            });

            proc.on('exit', (code) => {
                this._log(`Process exited with code ${code}`);
                this.currentLocation = null;
                this._updateStatus(false);
                this._stopTrafficMonitor();
                this._clearPidFile();
                if (!settled) {
                    settled = true;
                    reject(new Error(this._friendlyError(lastError) || `Core exited (code ${code})`));
                }
            });

            // Readiness: poll the Clash API. The moment it answers, the core is
            // up and the TUN is established — typically a few hundred ms.
            const deadline = Date.now() + 12000;
            const poll = async () => {
                if (settled) return;
                if (!this.singboxProcess) return;

                let ready = false;
                try {
                    const r = await this._httpGet(clashPort, '/version', 1500);
                    ready = !!(r && r.ok);
                } catch (e) {
                    ready = false;
                }
                if (settled) return;

                if (ready) {
                    settled = true;
                    this._onReady(location);
                    resolve({ success: true, location });
                    return;
                }

                if (Date.now() > deadline) {
                    // Lenient fallback: the process is alive but the API never
                    // answered. Assume the tunnel is up rather than tearing a
                    // possibly-working connection down.
                    if (this.singboxProcess && !this.singboxProcess.killed) {
                        settled = true;
                        this._log('Clash API not reachable; assuming tunnel is up');
                        this._onReady(location);
                        resolve({ success: true, location });
                    } else {
                        settled = true;
                        reject(new Error(this._friendlyError(lastError) || 'Connection timeout'));
                    }
                    return;
                }
                setTimeout(poll, 150);
            };
            setTimeout(poll, 150);
        });
    }

    _onReady(location) {
        this.currentLocation = location;
        this._updateStatus(true);
        this._startTrafficMonitor();
    }

    /** Turn a raw sing-box stderr line into something a human can act on. */
    _friendlyError(raw) {
        if (!raw) return '';
        const lower = raw.toLowerCase();
        if (lower.includes('permission') || lower.includes('access is denied') || lower.includes('operation not permitted')) {
            return 'Administrator rights are required to start the tunnel.';
        }
        if (lower.includes('address already in use') || lower.includes('bind')) {
            return 'A network port is already in use. Try again.';
        }
        if (lower.includes('reality') || lower.includes('handshake') || lower.includes('tls')) {
            return 'Handshake failed — check the server link or try another location.';
        }
        // Strip ANSI/log noise and keep the last meaningful chunk.
        const clean = raw.replace(/\[[0-9;]*m/g, '').split('\n').filter(Boolean).pop() || '';
        return clean.length > 160 ? clean.slice(0, 160) + '…' : clean;
    }

    async disconnect() {
        // Immediately mark as disconnected to prevent connect() from calling disconnect() again
        const wasConnected = this.isConnected;
        this.isConnected = false;
        this._stopTrafficMonitor();
        this.clashPort = null;
        this.currentLocation = null;

        // Notify listeners (UI + tray) that we're disconnected. disconnect() sets
        // the flag directly (not via _updateStatus) and removes the process exit
        // listener, so this is the only place the "disconnected" event can fire.
        if (wasConnected && this.statusCallback) {
            this.statusCallback({ isConnected: false, location: null });
        }

        if (this.singboxProcess) {
            // Capture reference to THIS process (not the class property which may change)
            const processToKill = this.singboxProcess;
            this.singboxProcess = null;

            return new Promise((resolve) => {
                // Remove ALL listeners first to prevent old handlers from firing
                processToKill.removeAllListeners('exit');
                processToKill.removeAllListeners('error');

                let resolved = false;
                const cleanup = () => {
                    if (!resolved) {
                        resolved = true;
                        this.currentLocation = null;
                        this._clearPidFile();
                        resolve();
                    }
                };

                processToKill.once('exit', cleanup);

                // Graceful shutdown — lets sing-box restore routes/firewall rules.
                processToKill.kill('SIGTERM');

                // Force kill after timeout
                setTimeout(() => {
                    if (!resolved) {
                        processToKill.removeListener('exit', cleanup);
                        try {
                            processToKill.kill('SIGKILL');
                        } catch (e) { /* process may already be dead */ }
                        cleanup();
                    }
                }, 2000);
            });
        }

        this.currentLocation = null;
        this._clearPidFile();
    }

    async cleanup() {
        await this.disconnect();
    }

    _updateStatus(isConnected) {
        if (this.isConnected !== isConnected) {
            this.isConnected = isConnected;
            if (this.statusCallback) {
                this.statusCallback({
                    isConnected: this.isConnected,
                    location: this.currentLocation
                });
            }
        }
    }

    onStatusChange(callback) {
        this.statusCallback = callback;
    }

    // ============ Traffic monitoring ============
    // Polls the Clash API /connections endpoint once per second. Speed is the
    // delta of cumulative byte counters over real elapsed time, so totals are
    // exact (no error from summing per-tick speeds).

    _startTrafficMonitor() {
        this._stopTrafficMonitor();
        this._prevDown = null;
        this._prevUp = null;
        this._prevT = null;

        this.trafficInterval = setInterval(async () => {
            if (!this.isConnected || !this.clashPort) return;

            let r;
            try {
                r = await this._httpGet(this.clashPort, '/connections', 2000);
            } catch (e) {
                return; // monitoring blip — never touches the tunnel itself
            }
            if (!r || !r.ok) return;

            let data;
            try { data = JSON.parse(r.body); } catch (e) { return; }

            const downTotal = data.downloadTotal || 0;
            const upTotal = data.uploadTotal || 0;
            const now = Date.now();

            let down = 0;
            let up = 0;
            if (this._prevT !== null) {
                const dt = (now - this._prevT) / 1000;
                if (dt > 0) {
                    down = Math.max(0, Math.round((downTotal - this._prevDown) / dt));
                    up = Math.max(0, Math.round((upTotal - this._prevUp) / dt));
                }
            }
            this._prevT = now;
            this._prevDown = downTotal;
            this._prevUp = upTotal;

            if (this.trafficCallback) {
                this.trafficCallback({ up, down, upTotal, downTotal });
            }
        }, 1000);
    }

    _stopTrafficMonitor() {
        if (this.trafficInterval) {
            clearInterval(this.trafficInterval);
            this.trafficInterval = null;
        }
        this._prevDown = null;
        this._prevUp = null;
        this._prevT = null;
    }

    onTraffic(callback) {
        this.trafficCallback = callback;
    }

    onLog(callback) {
        this.logCallback = callback;
    }

    _log(message) {
        if (this.logCallback) {
            this.logCallback(message);
        }
    }

    getStatus() {
        return {
            isConnected: this.isConnected,
            currentLocation: this.currentLocation
        };
    }
}

module.exports = SingboxManager;
