import fs from "node:fs/promises";
import path from "node:path";
// ---------------------------------------------------------------------------
// Scannable extensions
// ---------------------------------------------------------------------------
const SCANNABLE_EXTENSIONS = new Set([
    ".js",
    ".ts",
    ".mjs",
    ".cjs",
    ".mts",
    ".cts",
    ".jsx",
    ".tsx",
]);
const DEFAULT_MAX_SCAN_FILES = 500;
const DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
export function isScannable(filePath) {
    return SCANNABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function isErrno(err, code) {
    if (!err || typeof err !== "object") {
        return false;
    }
    if (!("code" in err)) {
        return false;
    }
    return err.code === code;
}
const LINE_RULES = [
    {
        ruleId: "dangerous-exec",
        severity: "critical",
        message: "Shell command execution detected (child_process)",
        pattern: /\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/,
        requiresContext: /child_process/,
    },
    {
        ruleId: "dynamic-code-execution",
        severity: "critical",
        message: "Dynamic code execution detected",
        pattern: /\beval\s*\(|new\s+Function\s*\(/,
    },
    {
        ruleId: "crypto-mining",
        severity: "critical",
        message: "Possible crypto-mining reference detected",
        pattern: /stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i,
    },
    {
        ruleId: "suspicious-network",
        severity: "warn",
        message: "WebSocket connection to non-standard port",
        pattern: /new\s+WebSocket\s*\(\s*["']wss?:\/\/[^"']*:(\d+)/,
    },
];
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
const SOURCE_RULES = [
    {
        ruleId: "potential-exfiltration",
        severity: "warn",
        message: "File read combined with network send — possible data exfiltration",
        pattern: /readFileSync|readFile/,
        requiresContext: /\bfetch\b|\bpost\b|http\.request/i,
    },
    {
        ruleId: "obfuscated-code",
        severity: "warn",
        message: "Hex-encoded string sequence detected (possible obfuscation)",
        pattern: /(\\x[0-9a-fA-F]{2}){6,}/,
    },
    {
        ruleId: "obfuscated-code",
        severity: "warn",
        message: "Large base64 payload with decode call detected (possible obfuscation)",
        pattern: /(?:atob|Buffer\.from)\s*\(\s*["'][A-Za-z0-9+/=]{200,}["']/,
    },
    {
        ruleId: "env-harvesting",
        severity: "critical",
        message: "Environment variable access combined with network send — possible credential harvesting",
        pattern: /process\.env/,
        requiresContext: /\bfetch\b|\bpost\b|http\.request/i,
    },
];
// ---------------------------------------------------------------------------
// Core scanner
// ---------------------------------------------------------------------------
function truncateEvidence(evidence, maxLen = 120) {
    if (evidence.length <= maxLen) {
        return evidence;
    }
    return `${evidence.slice(0, maxLen)}…`;
}
export function scanSource(source, filePath) {
    const findings = [];
    const lines = source.split("\n");
    const matchedLineRules = new Set();
    // --- Line rules ---
    for (const rule of LINE_RULES) {
        if (matchedLineRules.has(rule.ruleId)) {
            continue;
        }
        // Skip rule entirely if context requirement not met
        if (rule.requiresContext && !rule.requiresContext.test(source)) {
            continue;
        }
        for (let i = 0; i < lines.length; i++) {
            const line = lines[i];
            const match = rule.pattern.exec(line);
            if (!match) {
                continue;
            }
            // Special handling for suspicious-network: check port
            if (rule.ruleId === "suspicious-network") {
                const port = parseInt(match[1], 10);
                if (STANDARD_PORTS.has(port)) {
                    continue;
                }
            }
            findings.push({
                ruleId: rule.ruleId,
                severity: rule.severity,
                file: filePath,
                line: i + 1,
                message: rule.message,
                evidence: truncateEvidence(line.trim()),
            });
            matchedLineRules.add(rule.ruleId);
            break; // one finding per line-rule per file
        }
    }
    // --- Source rules ---
    const matchedSourceRules = new Set();
    for (const rule of SOURCE_RULES) {
        // Allow multiple findings for different messages with the same ruleId
        // but deduplicate exact (ruleId+message) combos
        const ruleKey = `${rule.ruleId}::${rule.message}`;
        if (matchedSourceRules.has(ruleKey)) {
            continue;
        }
        if (!rule.pattern.test(source)) {
            continue;
        }
        if (rule.requiresContext && !rule.requiresContext.test(source)) {
            continue;
        }
        // Find the first matching line for evidence + line number
        let matchLine = 0;
        let matchEvidence = "";
        for (let i = 0; i < lines.length; i++) {
            if (rule.pattern.test(lines[i])) {
                matchLine = i + 1;
                matchEvidence = lines[i].trim();
                break;
            }
        }
        // For source rules, if we can't find a line match the pattern might span
        // lines. Report line 0 with truncated source as evidence.
        if (matchLine === 0) {
            matchLine = 1;
            matchEvidence = source.slice(0, 120);
        }
        findings.push({
            ruleId: rule.ruleId,
            severity: rule.severity,
            file: filePath,
            line: matchLine,
            message: rule.message,
            evidence: truncateEvidence(matchEvidence),
        });
        matchedSourceRules.add(ruleKey);
    }
    return findings;
}
// ---------------------------------------------------------------------------
// Directory scanner
// ---------------------------------------------------------------------------
function normalizeScanOptions(opts) {
    return {
        includeFiles: opts?.includeFiles ?? [],
        maxFiles: Math.max(1, opts?.maxFiles ?? DEFAULT_MAX_SCAN_FILES),
        maxFileBytes: Math.max(1, opts?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES),
    };
}
function isPathInside(basePath, candidatePath) {
    const base = path.resolve(basePath);
    const candidate = path.resolve(candidatePath);
    const rel = path.relative(base, candidate);
    return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
}
async function walkDirWithLimit(dirPath, maxFiles) {
    const files = [];
    const stack = [dirPath];
    while (stack.length > 0 && files.length < maxFiles) {
        const currentDir = stack.pop();
        if (!currentDir) {
            break;
        }
        const entries = await fs.readdir(currentDir, { withFileTypes: true });
        for (const entry of entries) {
            if (files.length >= maxFiles) {
                break;
            }
            // Skip hidden dirs and node_modules
            if (entry.name.startsWith(".") || entry.name === "node_modules") {
                continue;
            }
            const fullPath = path.join(currentDir, entry.name);
            if (entry.isDirectory()) {
                stack.push(fullPath);
            }
            else if (isScannable(entry.name)) {
                files.push(fullPath);
            }
        }
    }
    return files;
}
async function resolveForcedFiles(params) {
    if (params.includeFiles.length === 0) {
        return [];
    }
    const seen = new Set();
    const out = [];
    for (const rawIncludePath of params.includeFiles) {
        const includePath = path.resolve(params.rootDir, rawIncludePath);
        if (!isPathInside(params.rootDir, includePath)) {
            continue;
        }
        if (!isScannable(includePath)) {
            continue;
        }
        if (seen.has(includePath)) {
            continue;
        }
        let st = null;
        try {
            st = await fs.stat(includePath);
        }
        catch (err) {
            if (isErrno(err, "ENOENT")) {
                continue;
            }
            throw err;
        }
        if (!st?.isFile()) {
            continue;
        }
        out.push(includePath);
        seen.add(includePath);
    }
    return out;
}
async function collectScannableFiles(dirPath, opts) {
    const forcedFiles = await resolveForcedFiles({
        rootDir: dirPath,
        includeFiles: opts.includeFiles,
    });
    if (forcedFiles.length >= opts.maxFiles) {
        return forcedFiles.slice(0, opts.maxFiles);
    }
    const walkedFiles = await walkDirWithLimit(dirPath, opts.maxFiles);
    const seen = new Set(forcedFiles.map((f) => path.resolve(f)));
    const out = [...forcedFiles];
    for (const walkedFile of walkedFiles) {
        if (out.length >= opts.maxFiles) {
            break;
        }
        const resolved = path.resolve(walkedFile);
        if (seen.has(resolved)) {
            continue;
        }
        out.push(walkedFile);
        seen.add(resolved);
    }
    return out;
}
async function readScannableSource(filePath, maxFileBytes) {
    let st = null;
    try {
        st = await fs.stat(filePath);
    }
    catch (err) {
        if (isErrno(err, "ENOENT")) {
            return null;
        }
        throw err;
    }
    if (!st?.isFile() || st.size > maxFileBytes) {
        return null;
    }
    try {
        return await fs.readFile(filePath, "utf-8");
    }
    catch (err) {
        if (isErrno(err, "ENOENT")) {
            return null;
        }
        throw err;
    }
}
export async function scanDirectory(dirPath, opts) {
    const scanOptions = normalizeScanOptions(opts);
    const files = await collectScannableFiles(dirPath, scanOptions);
    const allFindings = [];
    for (const file of files) {
        const source = await readScannableSource(file, scanOptions.maxFileBytes);
        if (source == null) {
            continue;
        }
        const findings = scanSource(source, file);
        allFindings.push(...findings);
    }
    return allFindings;
}
export async function scanDirectoryWithSummary(dirPath, opts) {
    const scanOptions = normalizeScanOptions(opts);
    const files = await collectScannableFiles(dirPath, scanOptions);
    const allFindings = [];
    let scannedFiles = 0;
    for (const file of files) {
        const source = await readScannableSource(file, scanOptions.maxFileBytes);
        if (source == null) {
            continue;
        }
        scannedFiles += 1;
        const findings = scanSource(source, file);
        allFindings.push(...findings);
    }
    return {
        scannedFiles,
        critical: allFindings.filter((f) => f.severity === "critical").length,
        warn: allFindings.filter((f) => f.severity === "warn").length,
        info: allFindings.filter((f) => f.severity === "info").length,
        findings: allFindings,
    };
}
