// Python subprocess management for the embedded Slopsmith server.
// Starts the FastAPI server as a child process, monitors health,
// and forwards logs.

import { ChildProcess, spawn, spawnSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { app } from 'electron';
import * as http from 'http';
import * as net from 'net';
import * as os from 'os';
import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager';
import { isDebugEnabled } from './debug-log';

let pythonProcess: ChildProcess | null = null;
// A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL
// timer pending) but isn't confirmed dead yet. restartPython() nulls
// pythonProcess and waits ~1s before spawning the replacement, so for that
// window (and until the old child's SIGTERM lands, up to the 5s timer) the
// dying backend is reachable only here — an app quit in that window must be
// able to force-kill it synchronously, or it orphans (its own async timer
// can't fire once the Electron main process exits). Cleared on the child's
// `close` (or when the graceful timer force-kills it).
let stoppingPythonProcess: ChildProcess | null = null;
// Use 18000+ to avoid conflicting with Docker Slopsmith on 8000. The renderer
// loads from http://127.0.0.1:${serverPort}, so this is also the localStorage
// origin — keeping it stable across launches is what stops origin-keyed UI /
// plugin settings (left-handed mode, volume, …) from resetting (#491).
const PREFERRED_PORT = 18000;
let serverPort = PREFERRED_PORT;
let serverReady = false;
// Set by startPython when the backend cannot even be spawned (e.g. server.py
// missing). waitForPython checks this so a config error fails fast with a
// specific message instead of running out the full readiness timeout.
let startupError: string | null = null;
// True once waitForPython has confirmed the server with a successful HTTP
// probe. Until then, any child exit is a startup failure — serverReady
// alone is not enough, since the child can log "startup complete" and then
// exit before the probe succeeds.
let startupComplete = false;

export function getPythonPort(): number {
    return serverPort;
}

// Interface the backend binds. Default loopback (127.0.0.1); 0.0.0.0 (all
// interfaces, LAN-reachable) only when the user opts in via the desktop
// config's `lanAccess` flag. 0.0.0.0 still includes loopback, so the renderer
// (which loads http://127.0.0.1:${port}) is unaffected either way. See #441.
function getBindHost(): string {
    return getDesktopConfig().lanAccess ? '0.0.0.0' : '127.0.0.1';
}

// LAN URLs the backend is reachable at when LAN access is enabled — the
// machine's non-internal IPv4 addresses on the current server port. Used by
// the desktop UI to tell the user where to point their phone/other device.
// Returns [] when no external IPv4 interface is up.
export function getLanUrls(): string[] {
    const urls: string[] = [];
    const ifaces = os.networkInterfaces();
    for (const name of Object.keys(ifaces)) {
        for (const ni of ifaces[name] || []) {
            if (ni.family === 'IPv4' && !ni.internal) {
                urls.push(`http://${ni.address}:${serverPort}`);
            }
        }
    }
    return urls;
}

export interface StartupStatus {
    running: boolean;
    phase: string;
    message: string;
    /** Name of the plugin currently being loaded (raw string value from the backend `current_plugin` field). */
    currentPlugin: string;
    loaded: number;
    total: number;
    error?: string | null;
}

// Find an available port starting from 8000
async function findPort(startPort: number): Promise<number> {
    // Probe on the SAME interface the backend will bind (getBindHost): with
    // LAN access on we bind 0.0.0.0, where a port can be free on loopback yet
    // taken on another interface — a 127.0.0.1-only probe would call it free
    // and uvicorn would then fail with EADDRINUSE. Found in Codex review.
    const bindHost = getBindHost();
    return new Promise((resolve) => {
        const server = net.createServer();
        server.listen(startPort, bindHost, () => {
            server.close(() => resolve(startPort));
        });
        server.on('error', () => {
            resolve(findPort(startPort + 1));
        });
    });
}

function findPythonExecutable(): string {
    // In packaged app, look for bundled Python
    if (app.isPackaged) {
        const resourcesPath = process.resourcesPath;
        const candidates = [
            // Linux/macOS: venv with --copies or full runtime
            path.join(resourcesPath, 'python', 'runtime', 'bin', 'python3'),
            path.join(resourcesPath, 'python', 'runtime', 'bin', 'python'),
            // Windows: embedded Python
            path.join(resourcesPath, 'python', 'python.exe'),
            // Fallback paths
            path.join(resourcesPath, 'python', 'bin', 'python3'),
            path.join(resourcesPath, 'python', 'python3'),
            path.join(resourcesPath, 'python', 'python'),
        ];
        for (const candidate of candidates) {
            if (fs.existsSync(candidate)) return candidate;
        }
    }

    // Development: check for local venv first
    const venvPython = path.join(__dirname, '..', '..', '.venv', 'bin', 'python3');
    if (fs.existsSync(venvPython)) return venvPython;

    return 'python3';
}

function reapOrphanedPythonBackends(pythonPath: string): void {
    if (process.platform === 'win32') return;
    const result = spawnSync('ps', ['-ww', '-axo', 'pid=,ppid=,command='], PROBE_SPAWN);
    if (result.error || result.status !== 0 || typeof result.stdout !== 'string') return;

    const expectedPrefixes = new Set<string>();
    const uvicornArgs = ' -m uvicorn server:app';
    if (path.isAbsolute(pythonPath)) {
        expectedPrefixes.add(`${path.resolve(pythonPath)}${uvicornArgs}`);
    } else {
        expectedPrefixes.add(`${pythonPath}${uvicornArgs}`);
        expectedPrefixes.add(`${path.basename(pythonPath)}${uvicornArgs}`);
    }
    const expectedPrefixList = [...expectedPrefixes];
    const stalePids: number[] = [];
    for (const line of result.stdout.split('\n')) {
        const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
        if (!match) continue;
        const pid = Number(match[1]);
        const ppid = Number(match[2]);
        const command = match[3];
        // Only reap fully-orphaned backends (reparented to init). A matching
        // uvicorn process with a live parent could be another app instance's
        // managed backend or a developer's own `uvicorn server:app` on a
        // different port — killing those would be a cross-process regression.
        // The actual port-18000 holder is handled precisely by reclaimPort().
        if (!Number.isInteger(pid) || ppid !== 1) continue;
        if (!expectedPrefixList.some(prefix => command.startsWith(prefix))) continue;
        stalePids.push(pid);
    }

    for (const pid of stalePids) {
        try {
            process.kill(pid, 'SIGTERM');
        } catch (e: unknown) {
            const code = (e as NodeJS.ErrnoException)?.code;
            if (code !== 'ESRCH') console.warn(`[python] failed to reap orphaned backend ${pid} (${code})`);
        }
    }
    if (stalePids.length) {
        console.log(`[python] Reaped orphaned backend PIDs: ${stalePids.join(', ')}`);
    }
}

// True if the TCP port can be bound right now on the interface the backend
// will use (getBindHost) — loopback by default, 0.0.0.0 when LAN access is on,
// so the free/busy answer matches what uvicorn will actually attempt.
function isPortFree(port: number): Promise<boolean> {
    const bindHost = getBindHost();
    return new Promise((resolve) => {
        const probe = net.createServer();
        probe.once('error', () => resolve(false));
        probe.listen(port, bindHost, () => {
            probe.close(() => resolve(true));
        });
    });
}

// Poll until `port` is bindable or `timeoutMs` elapses; returns whether it
// became free. Killing a stale backend is asynchronous (SIGTERM/SIGKILL) and
// the OS can hold the listener briefly after the process dies, so binding
// immediately would drift to 18001 and change the renderer origin anyway.
// In the common case the first probe succeeds, so this adds no startup delay.
async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean> {
    const deadline = Date.now() + timeoutMs;
    for (;;) {
        if (await isPortFree(port)) return true;
        if (Date.now() >= deadline) return false;
        await new Promise((r) => setTimeout(r, 150));
    }
}

// Shared spawnSync options for the startup port-reclaim probes: a short
// timeout so a hung query (netstat / lsof / ps / powershell / taskkill) can
// never freeze app startup, and windowsHide so none of them flash a console
// window on Windows. On timeout, spawnSync sets `.error` (ETIMEDOUT), which
// every caller already treats as "couldn't query".
const PROBE_SPAWN: { encoding: BufferEncoding; timeout: number; windowsHide: boolean } = {
    encoding: 'utf8', timeout: 5000, windowsHide: true,
};

// Our backend is launched as
//   python -m uvicorn server:app --host <127.0.0.1|0.0.0.0> --port <p> …
// The host depends on the LAN-access setting (getBindHost), so accept either
// of our two signatures. Match on the full signature (not just the python
// image) so we never kill an unrelated orphaned uvicorn `server:app` that
// happens to hold the port.
function isOurBackendCmd(cmd: string): boolean {
    return cmd.includes('uvicorn')
        && cmd.includes('server:app')
        && (cmd.includes('--host 127.0.0.1') || cmd.includes('--host 0.0.0.0'));
}

// PIDs LISTENING at <port> on a local bind we might use — loopback
// (127.0.0.1 / ::1) OR all-interfaces (0.0.0.0 / ::), since the backend binds
// 0.0.0.0 when LAN access is enabled (see getBindHost). Cross-platform;
// returns [] when the query tool is unavailable, hangs, or nothing is
// listening. The caller (reclaimPort) only ever kills a PID that
// `backendProcInfo` confirms is *our* uvicorn backend, so matching a
// non-loopback listener here is safe — a stranger on the port is left alone.
function findPidsOnPort(port: number): number[] {
    const pids = new Set<number>();
    const isLocalBind = (addr: string): boolean =>
        addr === '127.0.0.1' || addr === '[::1]' || addr === '::1'
        || addr === '0.0.0.0' || addr === '[::]' || addr === '::' || addr === '*';
    if (process.platform === 'win32') {
        const out = spawnSync('netstat', ['-ano', '-p', 'tcp'], PROBE_SPAWN);
        if (out.error || typeof out.stdout !== 'string') return [];
        for (const line of out.stdout.split('\n')) {
            // Proto  Local Address        Foreign Address  State      PID
            //  TCP   0.0.0.0:18000        0.0.0.0:0        LISTENING  1234
            const m = line.trim().match(/^TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
            if (!m || Number(m[2]) !== port || !isLocalBind(m[1])) continue;
            const pid = Number(m[3]);
            if (Number.isInteger(pid) && pid > 0) pids.add(pid);
        }
    } else {
        // lsof ships with macOS and most Linux installs. Query by port only
        // (no @host) so it matches whether the holder bound loopback or
        // 0.0.0.0 — the kill decision is gated on isOurBackendCmd downstream.
        const out = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], PROBE_SPAWN);
        if (!out.error && typeof out.stdout === 'string') {
            for (const tok of out.stdout.split(/\s+/)) {
                const pid = Number(tok);
                if (Number.isInteger(pid) && pid > 0) pids.add(pid);
            }
        }
    }
    return [...pids];
}

// Is `pid` currently a live process? POSIX: signal 0 probes without killing.
// Windows: a PowerShell CIM query that prints a deterministic ALIVE/DEAD token.
//
// Fails SAFE: if we cannot positively query the process (PowerShell errored /
// non-zero / unparseable, or signal probe inconclusive), assume the process is
// alive. The only caller uses this to decide whether a port holder is orphaned,
// and a query failure must never let us conclude "parent is dead" and kill a
// live backend.
function isProcessAlive(pid: number): boolean {
    if (!Number.isInteger(pid) || pid <= 0) return false;
    if (process.platform === 'win32') {
        // PowerShell CIM gives a deterministic ALIVE/DEAD answer — unlike
        // `tasklist /FI`, whose exit status on a no-match (i.e. the process is
        // genuinely gone) varies by Windows version and could otherwise be
        // misread as "can't query".
        const out = spawnSync('powershell', ['-NoProfile', '-Command',
            `if (Get-CimInstance Win32_Process -Filter "ProcessId=${pid}") {'ALIVE'} else {'DEAD'}`],
            PROBE_SPAWN);
        if (out.error || out.status !== 0 || typeof out.stdout !== 'string') return true; // can't query → assume alive
        return out.stdout.includes('ALIVE');
    }
    try {
        process.kill(pid, 0);
        return true;
    } catch (e: unknown) {
        // EPERM means it exists but we can't signal it (still "alive").
        return (e as NodeJS.ErrnoException)?.code === 'EPERM';
    }
}

// Process identity for the port holder: is it one of our Python/uvicorn
// backends, and what is its parent pid? Returns null when the query tool is
// unavailable, the process is gone, it isn't our uvicorn backend, or the
// parent pid doesn't parse — callers then leave it alone (never kill on a
// guess).
function backendProcInfo(pid: number): { ppid: number } | null {
    if (process.platform === 'win32') {
        // wmic is deprecated/removed on newer Windows; PowerShell CIM is stable.
        // Verify the full command line, not just the image name, so we don't
        // kill an unrelated orphaned python that happens to hold the port.
        const out = spawnSync('powershell', ['-NoProfile', '-Command',
            `$p=Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; `
            + `if($p){"$($p.Name)|$($p.ParentProcessId)|$($p.CommandLine)"}`], PROBE_SPAWN);
        if (out.error || typeof out.stdout !== 'string') return null;
        const [name, ppidStr, ...cmdParts] = out.stdout.trim().split('|');
        const cmd = cmdParts.join('|');
        if (!/^python(w)?\.exe$/i.test((name || '').trim())) return null;
        if (!isOurBackendCmd(cmd)) return null;
        const ppid = Number(ppidStr);
        if (!Number.isInteger(ppid) || ppid <= 0) return null;
        return { ppid };
    }
    // -ww disables column truncation so the full command line is visible
    // (consistent with reapOrphanedPythonBackends).
    const out = spawnSync('ps', ['-ww', '-p', String(pid), '-o', 'ppid=,command='], PROBE_SPAWN);
    if (out.error || typeof out.stdout !== 'string') return null;
    const m = out.stdout.trim().match(/^(\d+)\s+(.+)$/);
    if (!m || !isOurBackendCmd(m[2])) return null;
    const ppid = Number(m[1]);
    if (!Number.isInteger(ppid) || ppid <= 0) return null;
    return { ppid };
}

// Kill the backend holding `port` — but ONLY if it's orphaned (its parent is
// gone). This covers the real bug (a backend left over from the previous run
// still holding 18000) on every platform, including Windows where the
// command-line reaper is a no-op, while never touching a *live* sibling
// instance's managed backend. If we can't positively confirm both "our
// backend" and "orphaned", we leave it and let waitForPortFree / findPort
// degrade gracefully to the current drift behavior.
function reclaimPort(port: number): void {
    for (const pid of findPidsOnPort(port)) {
        const info = backendProcInfo(pid);
        if (!info) {
            console.warn(`[python] port ${port} held by pid ${pid} (not a recognizable backend); leaving it alone`);
            continue;
        }
        // Orphan signal is platform-specific: POSIX reparents a dead parent's
        // child to init (ppid===1); Windows keeps the original ppid, so we
        // check whether that parent is still alive. A live non-init parent
        // means a running sibling instance owns it — never kill those.
        const orphaned = process.platform === 'win32'
            ? !isProcessAlive(info.ppid)
            : info.ppid === 1;
        if (!orphaned) {
            console.warn(`[python] port ${port} held by pid ${pid} with live parent ${info.ppid} (another running instance?); leaving it alone`);
            continue;
        }
        try {
            if (process.platform === 'win32') {
                const r = spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'], PROBE_SPAWN);
                if (r.error || r.status !== 0) {
                    console.warn(`[python] taskkill failed for port-${port} holder pid ${pid} `
                        + `(status=${r.status}${r.error ? `, ${r.error.message}` : ''}): ${(r.stderr || '').trim()}`);
                    continue;
                }
            } else {
                process.kill(pid, 'SIGKILL');
            }
            console.log(`[python] reclaimed port ${port} from orphaned backend pid ${pid}`);
        } catch (e: unknown) {
            const code = (e as NodeJS.ErrnoException)?.code;
            if (code !== 'ESRCH') console.warn(`[python] failed to reclaim port ${port} from pid ${pid} (${code})`);
        }
    }
}

function findSlopsmithDir(): string {
    // In packaged app, Slopsmith is bundled in resources
    if (app.isPackaged) {
        return path.join(process.resourcesPath, 'slopsmith');
    }

    // Development — same resolution order as scripts/setup-dev.sh:
    //   1. $SLOPSMITH_DIR
    //   2. ../slopsmith (sibling to slopsmith-desktop)
    //   3. ~/Repositories/slopsmith (legacy)
    // An explicit $SLOPSMITH_DIR is honoured verbatim — never fall through
    // to a sibling/legacy checkout, so a typo or partial checkout surfaces
    // as a clear "server.py not found" error in startPython instead of
    // silently starting a different Slopsmith. Matches the build scripts
    // (bundle-python.sh, build-macos.sh). For the unset case a fallback
    // candidate only counts if it actually contains server.py, so a partial
    // or unrelated ../slopsmith directory cannot mask a valid legacy
    // checkout.
    const isSlopsmithRepo = (dir: string): boolean =>
        fs.existsSync(path.join(dir, 'server.py'));

    // $SLOPSMITH_DIR must be a native path. On Windows, pass a native path
    // (C:\\src\\slopsmith), not an MSYS/Git-Bash path (/c/src/slopsmith) —
    // Node resolves the latter against the current drive root. See README.
    const explicit = process.env.SLOPSMITH_DIR;
    if (explicit) return path.resolve(explicit);

    const siblingPath = path.join(__dirname, '..', '..', '..', 'slopsmith');
    if (isSlopsmithRepo(siblingPath)) return siblingPath;

    // app.getPath('home') is the platform-native home (USERPROFILE on
    // Windows, HOME on POSIX). process.env.HOME is an MSYS-style path such
    // as /c/Users/name when Electron is launched from Git Bash, which Node
    // misresolves — see the cacheBase comment in startPython.
    const legacyPath = path.join(app.getPath('home'), 'Repositories', 'slopsmith');
    if (isSlopsmithRepo(legacyPath)) return legacyPath;

    return siblingPath;
}

function getConfigDir(): string {
    // Share config with the Docker Slopsmith instance so DLC dir, favorites etc. are shared
    const sharedConfig = path.join(process.env.HOME || '', '.local', 'share', 'slopsmith');
    if (fs.existsSync(sharedConfig)) return sharedConfig;

    // Fallback to app-specific config
    const configDir = path.join(app.getPath('userData'), 'slopsmith-config');
    if (!fs.existsSync(configDir)) {
        fs.mkdirSync(configDir, { recursive: true });
    }
    return configDir;
}

function getPluginsDir(): string {
    const pluginsDir = path.join(app.getPath('userData'), 'plugins');
    if (!fs.existsSync(pluginsDir)) {
        fs.mkdirSync(pluginsDir, { recursive: true });
    }
    return pluginsDir;
}

function getDLCDir(): string {
    if (process.env.DLC_DIR && fs.existsSync(process.env.DLC_DIR)) return process.env.DLC_DIR;

    // Read from shared config
    const configFile = path.join(getConfigDir(), 'config.json');
    if (fs.existsSync(configFile)) {
        try {
            const cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
            if (cfg.dlc_dir && fs.existsSync(cfg.dlc_dir)) return cfg.dlc_dir;
        } catch { /* ignore */ }
    }

    // Common default locations
    const home = process.env.HOME || process.env.USERPROFILE || '';
    const candidates = [
        path.join(home, '.local', 'share', 'slopsmith', 'library'),
        path.join(home, 'Music', 'Slopsmith'),
    ];
    for (const dir of candidates) {
        if (fs.existsSync(dir)) return dir;
    }

    return candidates[0]; // fallback even if not found
}

export async function startPython(): Promise<void> {
    startupError = null;
    startupComplete = false;
    const slopsmithDir = findSlopsmithDir();
    const serverScript = path.join(slopsmithDir, 'server.py');

    if (!fs.existsSync(serverScript)) {
        // findSlopsmithDir returns a bundled path when packaged and ignores
        // SLOPSMITH_DIR there — so the remediation differs by mode.
        startupError = `Slopsmith server.py not found at ${serverScript}. `
            + (app.isPackaged
                ? 'The application bundle is incomplete or corrupt — reinstall Slopsmith Desktop.'
                : 'Set SLOPSMITH_DIR to your Slopsmith checkout, clone it to ../slopsmith, '
                  + 'or use ~/Repositories/slopsmith/.');
        console.error(`[python] ${startupError}`);
        return;
    }

    // Reset the readiness flag — this matters on restarts (`restartPython`),
    // otherwise waitForPython would short-circuit on a flag set by the
    // previous spawn and probe a server that hasn't actually started yet.
    serverReady = false;

    const pythonPath = findPythonExecutable();
    // Keep the renderer origin stable across launches (#491): the renderer
    // loads from http://127.0.0.1:${serverPort}, so if a leftover backend
    // still holds the preferred port, findPort drifts to 18001+ and wipes
    // origin-keyed localStorage settings. Reap any orphaned POSIX backend
    // (cheap, single `ps`), then — ONLY if the preferred port is actually
    // busy — reclaim its (orphaned) holder and wait for the OS to release it.
    // The common case (port already free) does no netstat/lsof/ps probing.
    reapOrphanedPythonBackends(pythonPath);
    if (!(await isPortFree(PREFERRED_PORT))) {
        reclaimPort(PREFERRED_PORT);
        if (!(await waitForPortFree(PREFERRED_PORT, 3000))) {
            console.warn(`[python] preferred port ${PREFERRED_PORT} still busy after reclaim; `
                + 'renderer origin may drift and reset localStorage-backed settings');
        }
    }
    serverPort = await findPort(PREFERRED_PORT);
    const configDir = getConfigDir();
    const dlcDir = getDLCDir();
    const pluginsDir = getPluginsDir();
    const slopsmithPlugins = path.join(slopsmithDir, 'plugins');

    console.log(`[python] Starting ${pythonPath} ${serverScript} on port ${serverPort}`);
    console.log(`[python] Config dir: ${configDir}`);
    console.log(`[python] DLC dir: ${dlcDir}`);
    console.log(`[python] Slopsmith plugins: ${slopsmithPlugins}`);
    console.log(`[python] User plugins: ${pluginsDir}`);

    // Build PYTHONPATH to include slopsmith's lib directory
    const pythonPathParts = [
        slopsmithDir,
        path.join(slopsmithDir, 'lib'),
    ];
    // On Windows, include embedded Python's Lib/site-packages
    if (app.isPackaged && process.platform === 'win32') {
        const pythonDir = path.join(process.resourcesPath, 'python');
        pythonPathParts.push(path.join(pythonDir, 'Lib', 'site-packages'));
    }
    const pythonPathEnv = pythonPathParts.join(path.delimiter);

    // Pin ML model caches to a persistent root (XDG_CACHE_HOME if set,
    // otherwise the user's home/.cache) so demucs / torch / huggingface
    // weights survive across launches and stay shareable with any other
    // torch app on the machine. The libraries already pick those paths
    // by default, but spelling them out here keeps the cache anchored
    // even if a future Electron sandbox / AppImage relocates HOME.
    // sloppak_convert.py uses env.setdefault on TORCH_HOME and
    // XDG_CACHE_HOME, so values set here win over its CONFIG_DIR
    // fallback — the right behaviour for Desktop; Docker still falls
    // back to /config/torch_cache via the same setdefault.
    //
    // Home is resolved via app.getPath('home') rather than
    // process.env.HOME: HOME is unset on Windows (and some sandboxed
    // contexts), which would otherwise produce a relative `.cache` path
    // and pass HOME='' into the subprocess. app.getPath consults the
    // platform-correct source (HOME on POSIX, USERPROFILE on Windows).
    //
    // cacheBase derives from XDG_CACHE_HOME first so a user who pins
    // their cache to a non-default disk (e.g. XDG_CACHE_HOME=/mnt/big)
    // gets all three caches (XDG/TORCH/HF) under the same root rather
    // than splitting torch/HF off to ~/.cache.
    const homeDir = app.getPath('home');
    const cacheBase = process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache');

    // Build environment for Python process
    const pythonEnv: Record<string, string> = {
        ...process.env as Record<string, string>,
        PYTHONPATH: pythonPathEnv,
        CONFIG_DIR: configDir,
        DLC_DIR: dlcDir,
        SLOPSMITH_PLUGINS_DIR: pluginsDir,
        HOME: homeDir,
        XDG_CACHE_HOME: cacheBase,
        TORCH_HOME: process.env.TORCH_HOME || path.join(cacheBase, 'torch'),
        HF_HOME: process.env.HF_HOME || path.join(cacheBase, 'huggingface'),
        RESOURCESPATH: app.isPackaged
            ? process.resourcesPath
            : path.join(__dirname, '..', '..', 'resources'),
        PATH: (app.isPackaged
            ? path.join(process.resourcesPath, 'bin') + path.delimiter
            : path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter
        ) + (process.env.PATH || ''),
    };

    // Debug mode: raise the Slopsmith server's log level and tee its
    // structured logs to a file. lib/logging_setup.py reads LOG_LEVEL and
    // LOG_FILE natively, so no Slopsmith-side change is needed. The Python
    // logs go to their own file; the subprocess's stdout/stderr are still
    // forwarded as [python] lines into the main debug log.
    if (isDebugEnabled()) {
        pythonEnv.LOG_LEVEL = 'DEBUG';
        pythonEnv.LOG_FILE = path.join(app.getPath('logs'), 'slopsmith-python.log');
    }

    // Cap the library-scan worker pool to prevent memory exhaustion on
    // low-RAM machines. The scan ProcessPoolExecutor (server.py) defaults to
    // one worker per CPU core for CPU-bound metadata parsing; each spawned
    // worker independently loads the lib stack and
    // can transiently hold ~2 GiB while parsing large song files. On a stock
    // 8 GB M2 MacBook Air an uncapped 8-worker pool
    // consumed ~7.7 GB, drove the macOS compressor to 5.7 GB, starved
    // WindowServer, and triggered a userspace-watchdog kernel panic
    // (WindowServer unresponsive for 208 s — panic-full-2026-06-08-134816).
    //
    // Read by server.py via SLOPSMITH_MAX_SCAN_WORKERS (slopsmith#761), which
    // takes priority over the legacy SCAN_MAX_WORKERS Docker override.
    // Formula: reserve 2 GiB for OS/kernel, allow ~2 GiB per worker, and cap
    // at the physical CPU count and a hard ceiling of 4.
    //   8 GB  → max(1, min(4, floor((8-2)/2)=3, cpus)) = 3
    //   16 GB → max(1, min(4, floor((16-2)/2)=7, cpus)) = 4
    //   4 GB  → max(1, min(4, floor((4-2)/2)=1, cpus)) = 1
    const totalGiB = os.totalmem() / (1024 ** 3);
    const workersByMemory = Math.floor((totalGiB - 2) / 2.0);
    const maxScanWorkers = Math.max(1, Math.min(4, workersByMemory, os.cpus().length));
    pythonEnv.SLOPSMITH_MAX_SCAN_WORKERS = String(maxScanWorkers);

    // Honour the "Audio Quality" preference: if the user has opted into the
    // high-quality FluidR3 soundfont and the file exists, point Python at it.
    // Otherwise fall through to the bundled GeneralUser GS via RESOURCESPATH.
    const activeSoundfont = getActiveSoundfontPath();
    if (activeSoundfont) {
        pythonEnv.SLOPSMITH_SOUNDFONT = activeSoundfont;
    }

    // Set PYTHONHOME for bundled Python on all platforms
    if (app.isPackaged) {
        if (process.platform === 'win32') {
            pythonEnv.PYTHONHOME = path.join(process.resourcesPath, 'python');
        } else {
            const runtimeDir = path.join(process.resourcesPath, 'python', 'runtime');
            pythonEnv.PYTHONHOME = runtimeDir;
            if (process.platform === 'linux') {
                const pythonLibDir = path.join(runtimeDir, 'lib');
                pythonEnv.LD_LIBRARY_PATH = pythonLibDir + path.delimiter + (process.env.LD_LIBRARY_PATH || '');
            }
        }

        // Point stdlib SSL at the bundled certifi CA bundle. `requests`
        // uses certifi automatically, but Slopsmith's own update checker
        // (and community plugins like slopsmith-update-manager) call
        // `urllib.request.urlopen`, which falls back to the platform's
        // system CA bundle and fails inside the AppImage with
        // `[SSL: CERTIFICATE_VERIFY_FAILED] unable to get local issuer
        // certificate` when /etc/ssl/certs isn't reachable from the
        // mount. SSL_CERT_FILE makes stdlib SSL pick certifi too.
        const certifiPath = process.platform === 'win32'
            ? path.join(process.resourcesPath, 'python', 'Lib', 'site-packages', 'certifi', 'cacert.pem')
            : (() => {
                const libDir = path.join(process.resourcesPath, 'python', 'runtime', 'lib');
                if (fs.existsSync(libDir)) {
                    for (const entry of fs.readdirSync(libDir)) {
                        if (entry.startsWith('python')) {
                            const p = path.join(libDir, entry, 'site-packages', 'certifi', 'cacert.pem');
                            if (fs.existsSync(p)) return p;
                        }
                    }
                }
                return '';
            })();
        if (certifiPath && fs.existsSync(certifiPath)) {
            pythonEnv.SSL_CERT_FILE = certifiPath;
            pythonEnv.REQUESTS_CA_BUNDLE = certifiPath;
        } else {
            console.warn('[python] certifi cacert.pem not found in bundle — HTTPS verification may fail');
        }
    }

    const bindHost = getBindHost();
    if (bindHost !== '127.0.0.1') {
        console.log(`[python] LAN access enabled — binding ${bindHost} (reachable from other devices on the network)`);
    }
    const child = spawn(pythonPath, [
        '-m', 'uvicorn', 'server:app',
        '--host', bindHost,
        '--port', String(serverPort),
        '--no-access-log',
    ], {
        cwd: slopsmithDir,
        env: pythonEnv,
        stdio: ['pipe', 'pipe', 'pipe'],
        // POSIX: give the child its own process group so stopPython() can kill
        // the whole tree (uvicorn can spawn worker/reloader children). Without
        // this, a plain kill leaves orphans holding the server port — the next
        // launch then drifts to 18001+, changing the renderer origin and
        // wiping origin-keyed localStorage (plugin settings reset every start).
        // The handle is deliberately NOT unref()'d — the main process keeps
        // tracking the child so shutdown() / stopPython() can terminate it
        // (detached only controls the process group, not the child's lifetime).
        detached: process.platform !== 'win32',
    });
    pythonProcess = child;

    // Flip `serverReady` when uvicorn emits a startup signal on either
    // stdout or stderr. structlog routes these messages to stdout in dev
    // mode; plain uvicorn uses stderr — so we watch both.
    function checkReadiness(msg: string): void {
        if (!serverReady && (msg.includes('Uvicorn running on') || msg.includes('Application startup complete'))) {
            serverReady = true;
        }
    }

    child.stdout?.on('data', (data: Buffer) => {
        try {
            const msg = data.toString().trim();
            if (msg) {
                console.log(`[python:stdout] ${msg}`);
                checkReadiness(msg);
            }
        } catch { /* EPIPE or similar when the process dies mid-write */ }
    });

    // Swallow stream errors — EPIPE fires when the Python child dies while
    // we're still draining its pipes; that's expected, not a bug.
    child.stdout?.on('error', () => { /* ignore */ });

    child.stderr?.on('data', (data: Buffer) => {
        try {
            const msg = data.toString().trim();
            if (msg) {
                console.log(`[python] ${msg}`);
                checkReadiness(msg);
            }
        } catch { /* EPIPE or similar when the process dies mid-write */ }
    });

    child.stderr?.on('error', () => { /* ignore */ });

    child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
        // Ignore events from a child that restartPython has already
        // replaced — a stale 'close' must not null out the new process
        // or fail its startup.
        if (pythonProcess !== child) return;
        // On a signal exit Node reports code === null; surface the signal
        // so the cause (SIGKILL/OOM, SIGTERM, ...) is not lost.
        const exitDesc = code !== null ? `code ${code}` : `signal ${signal ?? 'unknown'}`;
        console.log(`[python] Process exited with ${exitDesc}`);
        // If the child exits before waitForPython confirmed the server
        // (HTTP probe), record a startupError so waitForPython fails fast
        // instead of polling out the full timeout. serverReady alone is not
        // enough — the child can log "startup complete" then exit before
        // the probe succeeds. startupComplete === true means startup already
        // succeeded (a later crash is not a startup failure).
        if (!startupComplete && !startupError) {
            startupError = `Python process exited before startup completed (${exitDesc}).`;
        }
        pythonProcess = null;
        serverReady = false;
    });

    child.on('error', (err: Error) => {
        // Ignore errors from a child restartPython has already replaced.
        if (pythonProcess !== child) return;
        console.error(`[python] Failed to start: ${err.message}`);
        // spawn itself failed (e.g. interpreter not found) — surface it to
        // waitForPython rather than waiting out the readiness timeout.
        if (!startupError) {
            startupError = `Failed to start Python process: ${err.message}`;
        }
        pythonProcess = null;
    });
}

export async function waitForPython(): Promise<number> {
    // Wait for the python child process we just spawned to be actually
    // ready to serve. Two signals are required, in order:
    //
    //   1. The stdout-or-stderr handler (via checkReadiness) flips
    //      `serverReady` to true when it sees uvicorn print
    //      "Application startup complete" or "Uvicorn running on".
    //      structlog routes these to stdout in dev mode; plain uvicorn
    //      uses stderr — so we watch both. This is the authoritative
    //      readiness signal — it comes from the child process we just
    //      spawned, so it can't be fooled by a zombie python from a
    //      prior crashed launch still listening on a different port.
    //
    //   2. Once that flag flips, a single HTTP probe to /api/plugins
    //      confirms the listener is reachable on the port we picked.
    //      Belt-and-suspenders against any race between the stderr
    //      message and the socket actually accepting connections.
    //
    // Generous total budget — with ~36 bundled plugins (whisper / NAM /
    // torch get imported), first-run lifespan startup easily exceeds
    // one minute on a cold cache.
    const maxAttempts = 600; // 5 minutes
    const intervalMs = 500;
    for (let i = 0; i < maxAttempts; i++) {
        // Fail fast on a config error startPython already diagnosed (e.g.
        // server.py missing) rather than waiting out the whole timeout.
        if (startupError) throw new Error(startupError);

        if (serverReady) {
            const ok = await new Promise<boolean>((resolve) => {
                const req = http.get(`http://127.0.0.1:${serverPort}/api/plugins`, (res) => {
                    resolve((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 500);
                });
                req.on('error', () => resolve(false));
                req.setTimeout(2000, () => { req.destroy(); resolve(false); });
            });
            if (ok) {
                startupComplete = true;
                return serverPort;
            }
            // serverReady was set but HTTP probe failed — fall through
            // and retry (lifespan-complete may briefly precede socket
            // accept on slow machines).
        }

        // Periodic progress so a long startup doesn't look like a freeze.
        // Logged every 10 s so the dev console / launcher log shows life.
        if (i > 0 && (i * intervalMs) % 10000 === 0) {
            console.log(`[python] waiting for server on port ${serverPort} (${(i * intervalMs) / 1000}s elapsed)`);
        }
        await new Promise((r) => setTimeout(r, intervalMs));
    }

    throw new Error(`Python server failed to start on port ${serverPort} within ${(maxAttempts * intervalMs) / 1000}s`);
}

// Kill the Python child *and any subprocesses it spawned* (uvicorn worker /
// reloader children). The child is spawned `detached` on POSIX so it leads its
// own process group; signalling the negated PID reaps the whole group, so no
// orphan survives holding the server port. Windows has no POSIX process
// groups here — fall back to a plain kill of the child itself.
function killPythonTree(proc: ChildProcess, signal: NodeJS.Signals): void {
    if (process.platform !== 'win32' && typeof proc.pid === 'number') {
        try {
            process.kill(-proc.pid, signal);
            return;
        } catch (e: unknown) {
            const code = (e as NodeJS.ErrnoException)?.code;
            // ESRCH — the process group has already exited. Nothing to do.
            if (code === 'ESRCH') return;
            // EPERM / EINVAL / anything else: the group kill did not land, so
            // fall through to a plain single-process kill rather than leaving
            // the server (and the port) alive silently.
            console.warn(`[python] process-group ${signal} failed (${code}); `
                + 'falling back to single-process kill');
        }
    }
    try {
        proc.kill(signal);
    } catch (e: unknown) {
        const code = (e as NodeJS.ErrnoException)?.code;
        // ESRCH — the child is already gone. Anything else (EPERM/…) means
        // the kill did not land; log it so a failed shutdown is diagnosable.
        if (code !== 'ESRCH') {
            console.warn(`[python] ${signal} of child failed (${code})`);
        }
    }
}

// `immediate` (app quit): kill the backend group synchronously with SIGKILL
// instead of the graceful SIGTERM + async force-kill timer. On quit the timer
// can NEVER fire — the Electron main process exits the instant the synchronous
// before-quit → shutdown() chain returns, so any pending setTimeout is dropped.
// Worse, uvicorn's SIGTERM handler does a *graceful* shutdown that blocks on
// in-flight requests (a library scan / sloppak conversion / demucs job can run
// for minutes), so a plain SIGTERM would orphan the backend — and the RAM of
// its loaded ML models — until the next launch's reaper (or forever, if the
// user doesn't relaunch). SQLite's WAL makes an abrupt stop crash-safe, and the
// user is closing the app, so abandoning in-flight work is the intended result.
export function stopPython(immediate = false): void {
    // Capture the child being stopped. During restartPython the global
    // pythonProcess may already point at a new child by the time the
    // force-kill timer fires — SIGTERM/SIGKILL must hit the one being
    // stopped, never the replacement.
    const proc = pythonProcess;
    if (!proc) {
        // No current backend — but a prior restart may have left one still
        // gracefully shutting down. On quit, force-kill it so it can't orphan
        // (its async SIGKILL timer won't fire once we exit).
        if (immediate && stoppingPythonProcess) {
            killPythonTree(stoppingPythonProcess, 'SIGKILL');
            stoppingPythonProcess = null;
        }
        return;
    }

    console.log(`[python] Stopping server...${immediate ? ' (immediate / app quit)' : ''}`);

    // The stop is intentional, so detach this child from module state now:
    //   - pythonProcess = null makes proc's own 'close'/'error' handlers
    //     no-op (their `pythonProcess !== child` guard), so the expected
    //     shutdown exit can't be recorded as a startup failure;
    //   - clearing the startup flags means a restart (and any later
    //     waitForPython) starts from a clean slate rather than throwing a
    //     stale startupError left by the stopped child.
    pythonProcess = null;
    startupError = null;
    startupComplete = false;
    serverReady = false;

    if (immediate) {
        // Quit: also force-kill any *other* backend still gracefully stopping
        // from a prior restart — its async timer can't fire once we exit.
        if (stoppingPythonProcess && stoppingPythonProcess !== proc) {
            killPythonTree(stoppingPythonProcess, 'SIGKILL');
        }
        stoppingPythonProcess = null;
        killPythonTree(proc, 'SIGKILL');
        return;
    }

    // Graceful stop: this child is dying but not yet reaped — track it so an
    // app quit during the wait can still force-kill it synchronously.
    stoppingPythonProcess = proc;

    let exited = false;
    let killTimeout: ReturnType<typeof setTimeout> | undefined;

    // Register the exit handler BEFORE signalling — a child that dies between
    // SIGTERM and here must still flip `exited` and cancel the force-kill, so
    // the timer can't fire a SIGKILL at a since-reused PID's process group.
    proc.once('close', () => {
        exited = true;
        if (stoppingPythonProcess === proc) stoppingPythonProcess = null;
        if (killTimeout) clearTimeout(killTimeout);
    });

    // Try graceful shutdown first — signal the whole process group so uvicorn's
    // children exit too and release the port.
    killPythonTree(proc, 'SIGTERM');

    // Force kill after a timeout — but skip it if the child is already gone
    // (exitCode/signalCode go non-null once it exits); signalling a stale,
    // possibly PID-reused process group otherwise.
    killTimeout = setTimeout(() => {
        if (stoppingPythonProcess === proc) stoppingPythonProcess = null;
        if (exited || proc.exitCode !== null || proc.signalCode !== null) return;
        console.log('[python] Force killing...');
        killPythonTree(proc, 'SIGKILL');
    }, 5000);
}

export function restartPython(): void {
    stopPython();
    // Wait a bit for port to be released, then restart
    setTimeout(() => startPython(), 1000);
}

export { getPluginsDir, getConfigDir, getDLCDir };
