import { networkInterfaces } from 'node:os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { logger } from '../utils/logger.js';
import { discoverContextWindows } from '../providers/context-discovery.js';
import { WELL_KNOWN_LOCAL_PORTS } from '../providers/well-known-endpoints.js';
import { resolveSurfaceDirectory } from '../runtime/surface-root.js';
import { instrumentedFetch } from '../utils/fetch-with-timeout.js';
import { summarizeError } from '../utils/error-display.js';
function getPersistedPath(roots) {
    return resolveSurfaceDirectory(roots.homeDirectory, roots.surfaceRoot, 'discovered-providers.json');
}
/** Load previously discovered providers from disk. Returns empty array if file doesn't exist. */
export function loadPersistedProviders(roots) {
    const persistedPath = getPersistedPath(roots);
    try {
        if (!existsSync(persistedPath))
            return [];
        const raw = readFileSync(persistedPath, 'utf-8');
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) {
            logger.warn('[Scanner] loadPersistedProviders ignored invalid discovery cache', { path: persistedPath });
            return [];
        }
        // Filter to only valid-shaped entries before trusting persisted data
        return parsed.filter((item) => typeof item === 'object' && item !== null &&
            typeof item.host === 'string' &&
            typeof item.port === 'number' &&
            Array.isArray(item.models));
    }
    catch (err) {
        logger.warn('[Scanner] loadPersistedProviders failed; using empty discovery cache', {
            path: persistedPath,
            error: summarizeError(err),
        });
        return [];
    }
}
/** Save discovered providers to disk. Merges with existing entries keyed by host:port. */
export function persistProviders(roots, servers) {
    const persistedPath = getPersistedPath(roots);
    try {
        const now = Date.now();
        // Load existing persisted servers
        let existing = [];
        if (existsSync(persistedPath)) {
            try {
                const raw = readFileSync(persistedPath, 'utf-8');
                const parsed = JSON.parse(raw);
                if (Array.isArray(parsed)) {
                    existing = parsed.filter((item) => typeof item === 'object' && item !== null &&
                        typeof item.host === 'string' &&
                        typeof item.port === 'number' &&
                        Array.isArray(item.models));
                }
                else {
                    logger.warn('[Scanner] persistProviders ignored invalid existing discovery cache; overwriting with current scan results', {
                        path: persistedPath,
                    });
                }
            }
            catch (err) {
                existing = [];
                logger.warn('[Scanner] persistProviders could not read existing discovery cache; overwriting with current scan results', {
                    path: persistedPath,
                    error: summarizeError(err),
                });
            }
        }
        // Merge: update existing entries, add new ones
        const byKey = new Map(existing.map(s => [`${s.host}:${s.port}`, s]));
        for (const server of servers) {
            byKey.set(`${server.host}:${server.port}`, { ...server, lastSeen: now });
        }
        mkdirSync(dirname(persistedPath), { recursive: true });
        writeFileSync(persistedPath, JSON.stringify([...byKey.values()], null, 2) + '\n', 'utf-8');
    }
    catch (err) {
        // Discovery still returns live results, but the cache was not updated.
        logger.warn('[Scanner] persistProviders failed; discovery cache was not updated', {
            path: persistedPath,
            serverCount: servers.length,
            error: summarizeError(err),
        });
    }
}
/** Remove specific servers from the persisted file (by host:port). */
export function removePersistedProviders(roots, toRemove) {
    if (toRemove.length === 0)
        return;
    const persistedPath = getPersistedPath(roots);
    try {
        if (!existsSync(persistedPath))
            return;
        const raw = readFileSync(persistedPath, 'utf-8');
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) {
            logger.warn('[Scanner] removePersistedProviders skipped invalid discovery cache', { path: persistedPath });
            return;
        }
        const current = parsed;
        const removeKeys = new Set(toRemove.map(s => `${s.host}:${s.port}`));
        const filtered = current.filter(s => !removeKeys.has(`${s.host}:${s.port}`));
        writeFileSync(persistedPath, JSON.stringify(filtered, null, 2) + '\n', 'utf-8');
    }
    catch (err) {
        // Discovery cleanup should not block the caller, but stale cache entries remain.
        logger.warn('[Scanner] removePersistedProviders failed; discovery cache was not cleaned up', {
            path: persistedPath,
            removeCount: toRemove.length,
            error: summarizeError(err),
        });
    }
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const KNOWN_PORTS = [
    WELL_KNOWN_LOCAL_PORTS.lmStudio,
    WELL_KNOWN_LOCAL_PORTS.jan,
    WELL_KNOWN_LOCAL_PORTS.aphrodite,
    WELL_KNOWN_LOCAL_PORTS.gpt4all,
    5000,
    WELL_KNOWN_LOCAL_PORTS.koboldCpp,
    7860,
    8000,
    8001,
    WELL_KNOWN_LOCAL_PORTS.llamaCpp,
    WELL_KNOWN_LOCAL_PORTS.ollama,
];
const PROBE_TIMEOUT_MS = 200;
const MAX_CONCURRENT_PROBES = 50;
const METADATA_TIMEOUT_MS = 2000;
// ---------------------------------------------------------------------------
// Concurrency
// ---------------------------------------------------------------------------
async function asyncPool(limit, items, fn) {
    const executing = new Set();
    for (const item of items) {
        const p = fn(item).then(() => { executing.delete(p); });
        executing.add(p);
        if (executing.size >= limit)
            await Promise.race(executing);
    }
    await Promise.all(executing);
}
// ---------------------------------------------------------------------------
// Subnet Detection
// ---------------------------------------------------------------------------
/**
 * Returns all host IPs to scan from local network interfaces.
 * Each non-internal, non-link-local IPv4 address yields its /24 subnet
 * (192.168.1.1 ... 192.168.1.254).
 */
function getLocalSubnets() {
    const ips = [];
    const ifaces = networkInterfaces();
    for (const iface of Object.values(ifaces)) {
        if (!iface)
            continue;
        for (const addr of iface) {
            if (addr.family !== 'IPv4' || addr.internal)
                continue;
            // Skip link-local
            if (addr.address.startsWith('169.254.'))
                continue;
            const parts = addr.address.split('.');
            const prefix = parts.slice(0, 3).join('.');
            for (let i = 1; i <= 254; i++) {
                ips.push(`${prefix}.${i}`);
            }
        }
    }
    // Deduplicate (multiple interfaces on same subnet)
    return [...new Set(ips)];
}
// ---------------------------------------------------------------------------
// Probe
// ---------------------------------------------------------------------------
/**
 * Probes a single host:port. Returns probe result or null on any failure.
 * Never throws.
 */
async function probeHost(host, port) {
    // Attempt 1: /v1/models (OpenAI-compatible)
    const v1Result = await tryFetch(`http://${host}:${port}/v1/models`);
    if (v1Result !== null) {
        const models = extractV1Models(v1Result.body);
        if (models !== null) {
            return { host, port, models, headers: v1Result.headers, responseBody: v1Result.body };
        }
    }
    // Port 11434 with only /api/tags (no /v1/models) cannot be used as an OpenAI-compatible provider.
    return null;
}
async function tryFetch(url) {
    try {
        const res = await instrumentedFetch(url, {
            signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
        });
        if (!res.ok)
            return null;
        const body = await res.json();
        const headers = {};
        res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value.toLowerCase(); });
        return { body, headers };
    }
    catch (err) {
        logger.debug('discovery probe failed', { url, error: summarizeError(err) });
        return null;
    }
}
function extractV1Models(body) {
    if (typeof body === 'object' &&
        body !== null &&
        'data' in body &&
        Array.isArray(body.data)) {
        const data = body.data;
        const ids = data
            .filter((item) => typeof item === 'object' && item !== null && 'id' in item && typeof item.id === 'string')
            .map((item) => item.id);
        return ids;
    }
    return null;
}
// ---------------------------------------------------------------------------
// Server Identification
// ---------------------------------------------------------------------------
/**
 * Heuristic identification of the server software.
 */
function identifyServer(port, headers, responseBody) {
    const headerValues = Object.entries(headers)
        .map(([k, v]) => `${k}:${v}`)
        .join(' ');
    // Model IDs as a flat string for pattern matching
    let modelIds = '';
    if (typeof responseBody === 'object' &&
        responseBody !== null &&
        'data' in responseBody &&
        Array.isArray(responseBody.data)) {
        modelIds = responseBody.data
            .filter((item) => typeof item === 'object' && item !== null && 'id' in item)
            .map((item) => String(item.id))
            .join(' ');
    }
    if (port === WELL_KNOWN_LOCAL_PORTS.ollama)
        return 'ollama';
    if (port === WELL_KNOWN_LOCAL_PORTS.jan)
        return 'jan';
    if (port === WELL_KNOWN_LOCAL_PORTS.gpt4all)
        return 'gpt4all';
    if (port === WELL_KNOWN_LOCAL_PORTS.koboldCpp)
        return 'koboldcpp';
    if (port === WELL_KNOWN_LOCAL_PORTS.aphrodite)
        return 'aphrodite';
    if (port === WELL_KNOWN_LOCAL_PORTS.lmStudio || headerValues.includes('lmstudio') || modelIds.includes('lmstudio')) {
        return 'lm-studio';
    }
    if (Object.keys(headers).some((k) => k.startsWith('x-vllm')))
        return 'vllm';
    // Check server header on any port (llama.cpp/localai can run on non-standard ports)
    const serverHeader = headers['server'] ?? '';
    if (serverHeader.includes('llama'))
        return 'llamacpp';
    if (serverHeader.includes('localai'))
        return 'localai';
    if (headerValues.includes('text-generation-inference'))
        return 'tgi';
    return 'unknown';
}
// ---------------------------------------------------------------------------
// Context Window Detection
// ---------------------------------------------------------------------------
/**
 * Queries a discovered server for actual context window sizes of its models.
 * Returns a map of modelId -> contextWindow. Missing entries mean the server
 * didn't report a context length for that model.
 */
export async function fetchModelContextWindows(host, port, serverType, models) {
    const result = {};
    switch (serverType) {
        case 'ollama': {
            // Ollama: POST /api/show for each model — parallel to avoid blocking pool slot
            await Promise.allSettled(models.map(async (model) => {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/api/show`, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ name: model }),
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (!res.ok)
                        return;
                    const data = await res.json();
                    // Check model_info.context_length (newer Ollama)
                    const modelInfo = data.model_info;
                    if (modelInfo) {
                        // Keys vary: could be "<arch>.context_length" or just "context_length"
                        for (const [key, value] of Object.entries(modelInfo)) {
                            if (key.endsWith('context_length') && typeof value === 'number' && value > 0) {
                                result[model] = value;
                                logger.info(`[Scan] ${model}: context ${value} tokens`);
                                break;
                            }
                        }
                    }
                    // Also check the parameters string for num_ctx.
                    if (!result[model] && typeof data.parameters === 'string') {
                        const match = data.parameters.match(/num_ctx\s+(\d+)/);
                        if (match) {
                            const ctxLen = parseInt(match[1], 10);
                            result[model] = ctxLen;
                            logger.info(`[Scan] ${model}: context ${ctxLen} tokens`);
                        }
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] Failed to fetch context window for ${model}: ${err}`);
                }
            }));
            break;
        }
        case 'vllm': {
            // vLLM: GET /v1/models/{id} returns max_model_len — parallel to avoid blocking pool slot
            await Promise.allSettled(models.map(async (model) => {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/v1/models/${encodeURIComponent(model)}`, {
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (!res.ok)
                        return;
                    const data = await res.json();
                    if (typeof data.max_model_len === 'number' && data.max_model_len > 0) {
                        result[model] = data.max_model_len;
                        logger.info(`[Scan] ${model}: context ${data.max_model_len} tokens`);
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] Failed to fetch context window for ${model}: ${err}`);
                }
            }));
            break;
        }
        case 'llamacpp': {
            // llama.cpp: GET /props returns default_generation_settings.n_ctx (server-wide)
            try {
                const res = await instrumentedFetch(`http://${host}:${port}/props`, {
                    signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                });
                if (res.ok) {
                    const data = await res.json();
                    const settings = data.default_generation_settings;
                    if (settings && typeof settings.n_ctx === 'number' && settings.n_ctx > 0) {
                        const ctxLen = settings.n_ctx;
                        // llama.cpp typically serves one model, apply to all
                        for (const model of models) {
                            result[model] = ctxLen;
                            logger.info(`[Scan] ${model}: context ${ctxLen} tokens`);
                        }
                    }
                }
            }
            catch (err) {
                logger.debug(`[Scan] Failed to fetch llama.cpp props from ${host}:${port}: ${err}`);
            }
            break;
        }
        case 'lm-studio': {
            // LM Studio: use discoverContextWindows which probes /api/v1/models first (rich endpoint
            // with max_context_length per model), falling back through the verbose-first probe chain.
            const baseURL = `http://${host}:${port}/v1`;
            const discovered = await discoverContextWindows(baseURL);
            for (const [modelId, ctx] of discovered) {
                if (models.includes(modelId) || models.length === 0) {
                    result[modelId] = ctx;
                    logger.info(`[Scan] ${modelId}: context ${ctx} tokens (via discoverContextWindows)`);
                }
            }
            // If no results from rich endpoint, fall through to generic probe
            if (Object.keys(result).length > 0)
                break;
        }
        // falls through to generic probe when LM Studio rich endpoint yields nothing
        case 'localai':
        case 'jan':
        case 'gpt4all':
        case 'koboldcpp':
        case 'aphrodite':
        case 'tgi':
        case 'unknown':
        default: {
            // Generic: try /v1/models/{id} for each model, look for common context length fields — parallel
            await Promise.allSettled(models.map(async (model) => {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/v1/models/${encodeURIComponent(model)}`, {
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (!res.ok)
                        return;
                    const data = await res.json();
                    const contextLength = (typeof data.max_model_len === 'number' && data.max_model_len > 0 ? data.max_model_len : null) ??
                        (typeof data.context_length === 'number' && data.context_length > 0 ? data.context_length : null) ??
                        (typeof data.context_window === 'number' && data.context_window > 0 ? data.context_window : null) ??
                        (typeof data.max_context_length === 'number' && data.max_context_length > 0 ? data.max_context_length : null);
                    if (contextLength !== null) {
                        result[model] = contextLength;
                        logger.info(`[Scan] ${model}: context ${contextLength} tokens`);
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] Failed to fetch context window for ${model}: ${err}`);
                }
            }));
            // Try /props (llama.cpp-style) if no context windows were found.
            if (Object.keys(result).length === 0 && models.length > 0) {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/props`, {
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (res.ok) {
                        const data = await res.json();
                        const settings = data.default_generation_settings;
                        if (settings && typeof settings.n_ctx === 'number' && settings.n_ctx > 0) {
                            const ctxLen = settings.n_ctx;
                            for (const model of models) {
                                result[model] = ctxLen;
                                logger.info(`[Scan] ${model}: context ${ctxLen} tokens (via /props fallback)`);
                            }
                        }
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] /props fallback failed for ${host}:${port}: ${err}`);
                }
            }
            break;
        }
    }
    return result;
}
// ---------------------------------------------------------------------------
// Output Token Limit Detection
// ---------------------------------------------------------------------------
/**
 * Queries a discovered server for output token limits of its models.
 * Returns a map of modelId -> maxOutputTokens. Missing entries mean the server
 * didn't report an output limit for that model.
 */
async function fetchModelOutputLimits(host, port, serverType, models) {
    const result = {};
    switch (serverType) {
        case 'ollama': {
            // Ollama: POST /api/show for each model — look for num_predict in model_info or parameters
            await Promise.allSettled(models.map(async (model) => {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/api/show`, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ name: model }),
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (!res.ok)
                        return;
                    const data = await res.json();
                    // Check model_info for num_predict (newer Ollama)
                    const modelInfo = data.model_info;
                    if (modelInfo) {
                        for (const [key, value] of Object.entries(modelInfo)) {
                            if (key.endsWith('num_predict') && typeof value === 'number' && value > 0) {
                                result[model] = value;
                                logger.info(`[Scan] ${model}: output limit ${value} tokens`);
                                break;
                            }
                        }
                    }
                    // Also check the parameters string for num_predict.
                    if (!result[model] && typeof data.parameters === 'string') {
                        const match = data.parameters.match(/num_predict\s+(\d+)/);
                        if (match) {
                            const limit = parseInt(match[1], 10);
                            result[model] = limit;
                            logger.info(`[Scan] ${model}: output limit ${limit} tokens (from parameters)`);
                        }
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] Failed to fetch output limit for ${model}: ${err}`);
                }
            }));
            break;
        }
        case 'vllm': {
            // vLLM: GET /v1/models/{id} — look for max_completion_tokens
            await Promise.allSettled(models.map(async (model) => {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/v1/models/${encodeURIComponent(model)}`, {
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (!res.ok)
                        return;
                    const data = await res.json();
                    if (typeof data.max_completion_tokens === 'number' && data.max_completion_tokens > 0) {
                        result[model] = data.max_completion_tokens;
                        logger.info(`[Scan] ${model}: output limit ${data.max_completion_tokens} tokens`);
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] Failed to fetch output limit for ${model}: ${err}`);
                }
            }));
            break;
        }
        case 'llamacpp': {
            // llama.cpp: GET /props — look for default_generation_settings.n_predict
            try {
                const res = await instrumentedFetch(`http://${host}:${port}/props`, {
                    signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                });
                if (res.ok) {
                    const data = await res.json();
                    const settings = data.default_generation_settings;
                    if (settings && typeof settings.n_predict === 'number' && settings.n_predict > 0) {
                        const limit = settings.n_predict;
                        for (const model of models) {
                            result[model] = limit;
                            logger.info(`[Scan] ${model}: output limit ${limit} tokens`);
                        }
                    }
                }
            }
            catch (err) {
                logger.debug(`[Scan] Failed to fetch llama.cpp output limits from ${host}:${port}: ${err}`);
            }
            break;
        }
        case 'lm-studio':
        case 'localai':
        case 'jan':
        case 'gpt4all':
        case 'koboldcpp':
        case 'aphrodite':
        case 'tgi':
        case 'unknown':
        default: {
            // Generic: try /v1/models/{id} for each model, look for common output limit fields
            await Promise.allSettled(models.map(async (model) => {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/v1/models/${encodeURIComponent(model)}`, {
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (!res.ok)
                        return;
                    const data = await res.json();
                    const outputLimit = (typeof data.max_completion_tokens === 'number' && data.max_completion_tokens > 0 ? data.max_completion_tokens : null) ??
                        (typeof data.max_output_tokens === 'number' && data.max_output_tokens > 0 ? data.max_output_tokens : null);
                    if (outputLimit !== null) {
                        result[model] = outputLimit;
                        logger.info(`[Scan] ${model}: output limit ${outputLimit} tokens`);
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] Failed to fetch output limit for ${model}: ${err}`);
                }
            }));
            // Try /props (llama.cpp-style) for n_predict if no output limits were found.
            if (Object.keys(result).length === 0 && models.length > 0) {
                try {
                    const res = await instrumentedFetch(`http://${host}:${port}/props`, {
                        signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
                    });
                    if (res.ok) {
                        const data = await res.json();
                        const settings = data.default_generation_settings;
                        if (settings && typeof settings.n_predict === 'number' && settings.n_predict > 0) {
                            const limit = settings.n_predict;
                            for (const model of models) {
                                result[model] = limit;
                                logger.info(`[Scan] ${model}: output limit ${limit} tokens (via /props fallback)`);
                            }
                        }
                    }
                }
                catch (err) {
                    logger.debug(`[Scan] /props output limit fallback failed for ${host}:${port}: ${err}`);
                }
            }
            break;
        }
    }
    return result;
}
// ---------------------------------------------------------------------------
// Server Name
// ---------------------------------------------------------------------------
const SERVER_DISPLAY_NAMES = {
    'ollama': 'Ollama',
    'lm-studio': 'LM Studio',
    'vllm': 'vLLM',
    'llamacpp': 'llama.cpp',
    'localai': 'LocalAI',
    'tgi': 'TGI',
    'jan': 'Jan',
    'gpt4all': 'GPT4All',
    'koboldcpp': 'KoboldCPP',
    'aphrodite': 'Aphrodite',
};
/**
 * Builds a human-friendly provider name.
 */
function buildServerName(serverType, host, port) {
    const isLocal = host === '127.0.0.1' || host === 'localhost';
    if (serverType === 'unknown') {
        return `local-${host}:${port}`;
    }
    const display = SERVER_DISPLAY_NAMES[serverType] ?? serverType;
    return isLocal ? display : `${display} (${host})`;
}
// ---------------------------------------------------------------------------
// Scan Hosts
// ---------------------------------------------------------------------------
/**
 * Scans a list of hosts across all known ports.
 */
export async function scanHosts(hosts, onProgress) {
    const probes = [];
    for (const host of hosts) {
        for (const port of KNOWN_PORTS) {
            probes.push({ host, port });
        }
    }
    const total = probes.length;
    let completed = 0;
    const servers = [];
    await asyncPool(MAX_CONCURRENT_PROBES, probes, async ({ host, port }) => {
        const result = await probeHost(host, port);
        completed++;
        onProgress?.(completed, total);
        if (result === null)
            return;
        const serverType = identifyServer(port, result.headers, result.responseBody);
        const name = buildServerName(serverType, host, port);
        const baseURL = `http://${host}:${port}/v1`;
        // Fetch context windows and output limits in parallel (independent queries to the same server)
        const [modelContextWindows, modelOutputLimits] = await Promise.all([
            fetchModelContextWindows(host, port, serverType, result.models),
            fetchModelOutputLimits(host, port, serverType, result.models),
        ]);
        servers.push({ name, host, port, baseURL, models: result.models, serverType, modelContextWindows, modelOutputLimits });
        logger.info(`[Scan] Found ${name} at ${host}:${port} (${result.models.length} models)`);
    });
    return servers;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Scans only localhost (fast, ~100ms).
 */
export async function scanLocalhost() {
    const start = Date.now();
    const servers = await scanHosts(['127.0.0.1']);
    return {
        servers,
        scannedHosts: 1,
        scannedPorts: KNOWN_PORTS.length,
        durationMs: Date.now() - start,
    };
}
/**
 * Full scan: localhost first, then all /24 subnet IPs.
 * Returns merged, deduplicated results.
 */
export async function scan(onProgress) {
    const start = Date.now();
    // Scan localhost first
    const localhostServers = await scanHosts(['127.0.0.1']);
    // Collect subnet IPs, excluding loopback
    const subnetIPs = getLocalSubnets().filter((ip) => ip !== '127.0.0.1');
    const subnetServers = subnetIPs.length > 0
        ? await scanHosts(subnetIPs, onProgress)
        : [];
    // Merge and deduplicate by host:port
    const seen = new Set();
    const allServers = [];
    for (const server of [...localhostServers, ...subnetServers]) {
        const key = `${server.host}:${server.port}`;
        if (!seen.has(key)) {
            seen.add(key);
            allServers.push(server);
        }
    }
    const scannedHosts = 1 + subnetIPs.length;
    return {
        servers: allServers,
        scannedHosts,
        scannedPorts: KNOWN_PORTS.length,
        durationMs: Date.now() - start,
    };
}
