"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProjectDetectionService = void 0;
const logger_1 = require("../utils/logger");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const net_1 = __importDefault(require("net"));
const child_process_1 = require("child_process");
const util_1 = require("util");
const log = (0, logger_1.createLogger)('ProjectDetectionService');
const execAsync = (0, util_1.promisify)(child_process_1.exec);
const MIN_VALID_TCP_PORT = 1;
const MAX_EXCLUSIVE_VALID_TCP_PORT = 65_536;
const RIVET_LOOPBACK_SCAN_PORT_MIN = 4000;
const RIVET_LOOPBACK_SCAN_PORT_MAX = 4010;
const DEV_SERVER_HTTP_PROBE_TIMEOUT_MS = 2000;
const DEFAULT_DEV_SERVER_HOST_LABEL = 'localhost';
const FRAMEWORK_CONFIGS = [
    {
        name: 'nextjs',
        packageJsonIndicators: ['next'],
        configFiles: ['next.config.js', 'next.config.ts', 'next.config.mjs'],
        devCommand: 'dev',
        defaultPort: 3000,
    },
    {
        name: 'vite',
        packageJsonIndicators: ['vite'],
        configFiles: ['vite.config.js', 'vite.config.ts'],
        devCommand: 'dev',
        defaultPort: 5173,
    },
    {
        name: 'cra',
        packageJsonIndicators: ['react-scripts'],
        configFiles: [],
        devCommand: 'start',
        defaultPort: 3000,
    },
    {
        name: 'remix',
        packageJsonIndicators: ['@remix-run/'],
        configFiles: ['remix.config.js'],
        devCommand: 'dev',
        defaultPort: 3000,
    },
    {
        name: 'svelte',
        packageJsonIndicators: ['@sveltejs/kit', 'svelte'],
        configFiles: ['svelte.config.js'],
        devCommand: 'dev',
        defaultPort: 5173,
    },
];
class ProjectDetectionService {
    detectFramework(projectPath) {
        const packageJsonPath = path_1.default.join(projectPath, 'package.json');
        log.debug(`Looking for package.json at: ${packageJsonPath}`);
        log.debug(`Project path: ${projectPath}`);
        if (!fs_1.default.existsSync(packageJsonPath)) {
            // Plain static HTML/CSS sites have no package.json. The rest of the
            // server (StaticFileService, /static routes, root-served Rivet shell)
            // already supports framework='static' — without this check the agent
            // would tell the user to add a package.json or wrap their project in
            // Vite, which is exactly what we're avoiding.
            if (fs_1.default.existsSync(path_1.default.join(projectPath, 'index.html'))) {
                log.info('Detected static project (index.html present, no package.json)');
                return 'static';
            }
            // History-only / 0-to-1 workspace: `create_zero_to_one_project` writes
            // generated variants under `.rivet/variants/<sessionId>/...` and never
            // touches the workspace root itself, so a user who created variants
            // earlier and comes back to browse history has neither package.json
            // nor index.html. Treat any directory with a `.rivet/` marker as a
            // valid Rivet workspace and start the backend in static mode (no proxy,
            // no user dev server) so /api/variants/history/* and the variants
            // panel work for history browsing.
            if (fs_1.default.existsSync(path_1.default.join(projectPath, '.rivet'))) {
                log.info('Detected Rivet history workspace (.rivet present, no package.json / index.html)');
                return 'static';
            }
            // A nonexistent path is a real error — don't silently treat it as a
            // workspace. But an existing-but-empty directory is a legitimate
            // "start from a blank project" (0-to-1) entry point: the user opened
            // Rivet in an empty folder to generate variants from scratch. Treat it
            // as static so detect / open_visual_editor / start_variants proceed
            // instead of demanding a package.json. The git repo is bootstrapped
            // lazily (WorktreeManager.ensureGitRepo) and the 0-to-1 flow scaffolds
            // the actual content.
            if (!fs_1.default.existsSync(projectPath)) {
                throw new Error(`Project path does not exist: ${projectPath}`);
            }
            log.info('Empty workspace (no package.json / index.html / .rivet) — treating as a blank static start point');
            return 'static';
        }
        const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf8'));
        const dependencies = {
            ...packageJson.dependencies,
            ...packageJson.devDependencies,
        };
        // Check each framework config
        for (const config of FRAMEWORK_CONFIGS) {
            const hasIndicator = config.packageJsonIndicators.some((indicator) => {
                return Object.keys(dependencies).some((dep) => dep.includes(indicator));
            });
            if (hasIndicator) {
                // Double-check with config files if they exist
                const hasConfigFile = config.configFiles.length === 0 ||
                    config.configFiles.some((configFile) => fs_1.default.existsSync(path_1.default.join(projectPath, configFile)));
                if (hasConfigFile) {
                    log.info(`Detected ${config.name} framework`);
                    return config.name;
                }
            }
        }
        log.debug('Unknown framework, defaulting to Next.js');
        return 'nextjs';
    }
    async detectPackageManager(projectPath) {
        // Check for lockfiles
        if (fs_1.default.existsSync(path_1.default.join(projectPath, 'yarn.lock'))) {
            return 'yarn';
        }
        if (fs_1.default.existsSync(path_1.default.join(projectPath, 'pnpm-lock.yaml'))) {
            return 'pnpm';
        }
        if (fs_1.default.existsSync(path_1.default.join(projectPath, 'package-lock.json'))) {
            return 'npm';
        }
        // Default to npm
        return 'npm';
    }
    /**
     * Reads the configured dev server port from framework config files in the project.
     * Returns undefined if no port can be determined from config.
     */
    readConfiguredPort(projectPath) {
        const viteConfigFiles = [
            'vite.config.ts',
            'vite.config.js',
            'vite.config.mjs',
        ];
        for (const configFile of viteConfigFiles) {
            const configPath = path_1.default.join(projectPath, configFile);
            if (fs_1.default.existsSync(configPath)) {
                try {
                    const content = fs_1.default.readFileSync(configPath, 'utf8');
                    // Match server: { ... port: NNNN ... } (handles multiline and nested objects like proxy: { ... })
                    const match = content.match(/server\s*:\s*\{((?:[^{}]|\{[^{}]*\})*)port\s*:\s*(\d+)/s);
                    if (match) {
                        const port = parseInt(match[2], 10);
                        log.debug(`Found configured port ${port} in ${configFile}`);
                        return port;
                    }
                }
                catch {
                    // ignore parse errors
                }
            }
        }
        return undefined;
    }
    async findRunningDevServer(projectPath, excludePorts = [], preferredPort) {
        const normalizedProjectPath = this.normalizeFilesystemPath(projectPath);
        const fallbackPorts = [3000, 5173, 3001, 3002, 5174, 8080, 4200, 8000];
        // Put the project's configured port first so it's always tried before fallbacks
        const configuredPort = this.readConfiguredPort(projectPath);
        let commonPorts = configuredPort
            ? [configuredPort, ...fallbackPorts.filter((p) => p !== configuredPort)]
            : [...fallbackPorts];
        const isUsablePreferredPort = typeof preferredPort === 'number' &&
            preferredPort >= MIN_VALID_TCP_PORT &&
            preferredPort < MAX_EXCLUSIVE_VALID_TCP_PORT &&
            !excludePorts.includes(preferredPort);
        if (isUsablePreferredPort) {
            commonPorts = [
                preferredPort,
                ...commonPorts.filter((p) => p !== preferredPort),
            ];
        }
        // Try explicit IPv4/IPv6 loopback hosts independently.
        const hosts = [
            { urlHost: '127.0.0.1', ipFamily: 4 },
            { urlHost: '[::1]', ipFamily: 6 },
        ];
        for (const port of commonPorts) {
            // Never pick up a Rivet instance as the dev server (default 4000, but can be dynamic)
            if (port >= RIVET_LOOPBACK_SCAN_PORT_MIN && port <= RIVET_LOOPBACK_SCAN_PORT_MAX) {
                continue;
            }
            // Skip ports we know were already active before we started (likely another app)
            if (excludePorts.includes(port))
                continue;
            for (const host of hosts) {
                try {
                    const response = await fetch(`http://${host.urlHost}:${port}`, {
                        method: 'GET', // Changed from HEAD to GET to check content
                        signal: AbortSignal.timeout(DEV_SERVER_HTTP_PROBE_TIMEOUT_MS),
                    });
                    if (response.ok) {
                        const isOwnedBySelectedProject = await this.isPortOwnedByProject({
                            normalizedProjectPath,
                            port,
                            ipFamily: host.ipFamily,
                        });
                        if (!isOwnedBySelectedProject) {
                            log.debug(`Skipping ${host.urlHost}:${port} because listener process path does not match selected project`);
                            continue;
                        }
                        const text = await response.text();
                        // Basic heuristic: check if the project name or some unique string is in the source
                        // Or just check if it's NOT a Rivet desktop app
                        if (text.includes('rivet-desktop-renderer') ||
                            text.includes('onUpdateAvailable')) {
                            log.debug(`Found Rivet desktop renderer on port ${port}, skipping...`);
                            continue;
                        }
                        log.info(`Found running dev server on port ${port} (via ${host.urlHost}). Status: ${response.status}`);
                        return { port, host: 'localhost' };
                    }
                    else {
                        log.debug(`Checked http://${host.urlHost}:${port}, but returned status ${response.status}`);
                    }
                }
                catch (error) {
                    // Host/port not accessible, try next
                    log.trace(`Could not reach http://${host.urlHost}:${port}: ${error.message}`);
                    continue;
                }
            }
        }
        log.debug('No running dev server found');
        return null;
    }
    async detectProject(projectPath, overrideFramework, entryPath, excludePorts = [], preferredDevServerPort) {
        log.info(`Detecting project in: ${projectPath}`);
        const packageManager = await this.detectPackageManager(projectPath);
        const framework = overrideFramework || this.detectFramework(projectPath);
        // Skip dev server detection for static apps
        let devServerPort = 0;
        let devServerHost = DEFAULT_DEV_SERVER_HOST_LABEL;
        if (framework !== 'static') {
            const runningServer = await this.findRunningDevServer(projectPath, excludePorts, preferredDevServerPort);
            if (!runningServer) {
                throw new Error('No running dev server found. Please start your dev server first (e.g., npm run dev)');
            }
            devServerPort = runningServer.port;
            devServerHost = runningServer.host;
        }
        // Handle entry point for static projects
        let entryPoint;
        if (framework === 'static') {
            entryPoint = entryPath || 'index.html';
            const fullEntryPath = path_1.default.join(projectPath, entryPoint);
            // Check if entry point exists
            if (!fs_1.default.existsSync(fullEntryPath)) {
                throw new Error(`Entry point not found: ${entryPoint}. Please ensure the file exists or specify a different --entry path.`);
            }
            log.info(`Using entry point: ${entryPoint}`);
        }
        const project = {
            id: path_1.default.basename(projectPath),
            framework,
            path: projectPath,
            devServerPort,
            devServerHost,
            status: 'detected',
            packageManager,
            entryPoint,
        };
        log.info(`Detected project:`, project);
        return project;
    }
    isPortActive(port) {
        return new Promise((resolve) => {
            const socket = net_1.default.connect(port, 'localhost');
            socket.on('connect', () => {
                socket.destroy();
                resolve(true);
            });
            socket.on('error', () => {
                socket.destroy();
                resolve(false);
            });
            socket.setTimeout(500);
            socket.on('timeout', () => {
                socket.destroy();
                resolve(false);
            });
        });
    }
    normalizeFilesystemPath(inputPath) {
        const resolvedPath = path_1.default.resolve(inputPath);
        try {
            return fs_1.default.realpathSync(resolvedPath);
        }
        catch {
            return resolvedPath;
        }
    }
    async isPortOwnedByProject(options) {
        const { normalizedProjectPath, port, ipFamily } = options;
        const listenerPids = await this.getListeningPortPids({ port, ipFamily });
        if (listenerPids.length === 0) {
            return false;
        }
        for (const pid of listenerPids) {
            const processCwd = await this.getProcessCwd(pid);
            if (!processCwd) {
                continue;
            }
            const normalizedProcessPath = this.normalizeFilesystemPath(processCwd);
            if (normalizedProcessPath === normalizedProjectPath) {
                return true;
            }
        }
        return false;
    }
    async getListeningPortPids(options) {
        const { port, ipFamily } = options;
        try {
            const { stdout } = await execAsync(`lsof -nP -i${ipFamily}TCP:${port} -sTCP:LISTEN -Fp`);
            const pids = stdout
                .split('\n')
                .map((line) => line.trim())
                .filter((line) => line.startsWith('p'))
                .map((line) => parseInt(line.slice(1), 10))
                .filter((pid) => Number.isInteger(pid) && pid > 0);
            return [...new Set(pids)];
        }
        catch {
            return [];
        }
    }
    async getProcessCwd(pid) {
        if (!Number.isInteger(pid) || pid <= 0) {
            return null;
        }
        try {
            const { stdout } = await execAsync(`lsof -a -p ${pid} -d cwd -Fn`);
            const cwdLine = stdout
                .split('\n')
                .map((line) => line.trim())
                .find((line) => line.startsWith('n'));
            if (!cwdLine) {
                return null;
            }
            return cwdLine.slice(1) || null;
        }
        catch {
            return null;
        }
    }
}
exports.ProjectDetectionService = ProjectDetectionService;
//# sourceMappingURL=ProjectDetectionService.js.map