import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runCommandWithTimeout } from "../process/exec.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { extractArchive, fileExists, readJsonFile, resolveArchiveKind, resolvePackedRootDir, } from "../infra/archive.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
const defaultLogger = {};
function unscopedPackageName(name) {
    const trimmed = name.trim();
    if (!trimmed)
        return trimmed;
    return trimmed.includes("/") ? (trimmed.split("/").pop() ?? trimmed) : trimmed;
}
function safeDirName(input) {
    const trimmed = input.trim();
    if (!trimmed)
        return trimmed;
    return trimmed.replaceAll("/", "__");
}
function safeFileName(input) {
    return safeDirName(input);
}
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));
}
function extensionUsesSkippedScannerPath(entry) {
    const segments = entry.split(/[\\/]+/).filter(Boolean);
    return segments.some((segment) => segment === "node_modules" ||
        (segment.startsWith(".") && segment !== "." && segment !== ".."));
}
async function ensureOpenclawExtensions(manifest) {
    const extensions = manifest.openclaw?.extensions;
    if (!Array.isArray(extensions)) {
        throw new Error("package.json missing openclaw.extensions");
    }
    const list = extensions.map((e) => (typeof e === "string" ? e.trim() : "")).filter(Boolean);
    if (list.length === 0) {
        throw new Error("package.json openclaw.extensions is empty");
    }
    return list;
}
export function resolvePluginInstallDir(pluginId, extensionsDir) {
    const extensionsBase = extensionsDir
        ? resolveUserPath(extensionsDir)
        : path.join(CONFIG_DIR, "extensions");
    return path.join(extensionsBase, safeDirName(pluginId));
}
async function installPluginFromPackageDir(params) {
    const logger = params.logger ?? defaultLogger;
    const timeoutMs = params.timeoutMs ?? 120_000;
    const mode = params.mode ?? "install";
    const dryRun = params.dryRun ?? false;
    const manifestPath = path.join(params.packageDir, "package.json");
    if (!(await fileExists(manifestPath))) {
        return { ok: false, error: "extracted package missing package.json" };
    }
    let manifest;
    try {
        manifest = await readJsonFile(manifestPath);
    }
    catch (err) {
        return { ok: false, error: `invalid package.json: ${String(err)}` };
    }
    let extensions;
    try {
        extensions = await ensureOpenclawExtensions(manifest);
    }
    catch (err) {
        return { ok: false, error: String(err) };
    }
    const pkgName = typeof manifest.name === "string" ? manifest.name : "";
    const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
    if (params.expectedPluginId && params.expectedPluginId !== pluginId) {
        return {
            ok: false,
            error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
        };
    }
    const packageDir = path.resolve(params.packageDir);
    const forcedScanEntries = [];
    for (const entry of extensions) {
        const resolvedEntry = path.resolve(packageDir, entry);
        if (!isPathInside(packageDir, resolvedEntry)) {
            logger.warn?.(`extension entry escapes plugin directory and will not be scanned: ${entry}`);
            continue;
        }
        if (extensionUsesSkippedScannerPath(entry)) {
            logger.warn?.(`extension entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`);
        }
        forcedScanEntries.push(resolvedEntry);
    }
    // Scan plugin source for dangerous code patterns (warn-only; never blocks install)
    try {
        const scanSummary = await scanDirectoryWithSummary(params.packageDir, {
            includeFiles: forcedScanEntries,
        });
        if (scanSummary.critical > 0) {
            const criticalDetails = scanSummary.findings
                .filter((f) => f.severity === "critical")
                .map((f) => `${f.message} (${f.file}:${f.line})`)
                .join("; ");
            logger.warn?.(`WARNING: Plugin "${pluginId}" contains dangerous code patterns: ${criticalDetails}`);
        }
        else if (scanSummary.warn > 0) {
            logger.warn?.(`Plugin "${pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`);
        }
    }
    catch (err) {
        logger.warn?.(`Plugin "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`);
    }
    const extensionsDir = params.extensionsDir
        ? resolveUserPath(params.extensionsDir)
        : path.join(CONFIG_DIR, "extensions");
    await fs.mkdir(extensionsDir, { recursive: true });
    const targetDir = path.join(extensionsDir, safeDirName(pluginId));
    if (mode === "install" && (await fileExists(targetDir))) {
        return {
            ok: false,
            error: `插件已存在: ${targetDir} (请先删除)`,
        };
    }
    if (dryRun) {
        return {
            ok: true,
            pluginId,
            targetDir,
            manifestName: pkgName || undefined,
            version: typeof manifest.version === "string" ? manifest.version : undefined,
            extensions,
        };
    }
    logger.info?.(`Installing to ${targetDir}…`);
    let backupDir = null;
    if (mode === "update" && (await fileExists(targetDir))) {
        backupDir = `${targetDir}.backup-${Date.now()}`;
        await fs.rename(targetDir, backupDir);
    }
    try {
        await fs.cp(params.packageDir, targetDir, { recursive: true });
    }
    catch (err) {
        if (backupDir) {
            await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
            await fs.rename(backupDir, targetDir).catch(() => undefined);
        }
        return { ok: false, error: `failed to copy plugin: ${String(err)}` };
    }
    for (const entry of extensions) {
        const resolvedEntry = path.resolve(targetDir, entry);
        if (!isPathInside(targetDir, resolvedEntry)) {
            logger.warn?.(`extension entry escapes plugin directory: ${entry}`);
            continue;
        }
        if (!(await fileExists(resolvedEntry))) {
            logger.warn?.(`extension entry not found: ${entry}`);
        }
    }
    const deps = manifest.dependencies ?? {};
    const hasDeps = Object.keys(deps).length > 0;
    if (hasDeps) {
        logger.info?.("Installing plugin dependencies…");
        const npmRes = await runCommandWithTimeout(["npm", "install", "--omit=dev", "--quiet"], {
            timeoutMs: Math.max(timeoutMs, 300_000),
            cwd: targetDir,
        });
        // npm may exit non-zero due to funding/audit notices even on success;
        // treat it as a real failure only if node_modules was not created.
        const nodeModulesCreated = await fileExists(path.join(targetDir, "node_modules"));
        if (npmRes.code !== 0 && !nodeModulesCreated) {
            if (backupDir) {
                await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
                await fs.rename(backupDir, targetDir).catch(() => undefined);
            }
            return {
                ok: false,
                error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`,
            };
        }
    }
    if (backupDir) {
        await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined);
    }
    return {
        ok: true,
        pluginId,
        targetDir,
        manifestName: pkgName || undefined,
        version: typeof manifest.version === "string" ? manifest.version : undefined,
        extensions,
    };
}
export async function installPluginFromArchive(params) {
    const logger = params.logger ?? defaultLogger;
    const timeoutMs = params.timeoutMs ?? 120_000;
    const mode = params.mode ?? "install";
    const archivePath = resolveUserPath(params.archivePath);
    if (!(await fileExists(archivePath))) {
        return { ok: false, error: `archive not found: ${archivePath}` };
    }
    if (!resolveArchiveKind(archivePath)) {
        return { ok: false, error: `unsupported archive: ${archivePath}` };
    }
    const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plugin-"));
    const extractDir = path.join(tmpDir, "extract");
    await fs.mkdir(extractDir, { recursive: true });
    logger.info?.(`Extracting ${archivePath}…`);
    try {
        await extractArchive({
            archivePath,
            destDir: extractDir,
            timeoutMs,
            logger,
        });
    }
    catch (err) {
        return { ok: false, error: `failed to extract archive: ${String(err)}` };
    }
    let packageDir = "";
    try {
        packageDir = await resolvePackedRootDir(extractDir);
    }
    catch (err) {
        return { ok: false, error: String(err) };
    }
    return await installPluginFromPackageDir({
        packageDir,
        extensionsDir: params.extensionsDir,
        timeoutMs,
        logger,
        mode,
        dryRun: params.dryRun,
        expectedPluginId: params.expectedPluginId,
    });
}
export async function installPluginFromDir(params) {
    const dirPath = resolveUserPath(params.dirPath);
    if (!(await fileExists(dirPath))) {
        return { ok: false, error: `directory not found: ${dirPath}` };
    }
    const stat = await fs.stat(dirPath);
    if (!stat.isDirectory()) {
        return { ok: false, error: `not a directory: ${dirPath}` };
    }
    return await installPluginFromPackageDir({
        packageDir: dirPath,
        extensionsDir: params.extensionsDir,
        timeoutMs: params.timeoutMs,
        logger: params.logger,
        mode: params.mode,
        dryRun: params.dryRun,
        expectedPluginId: params.expectedPluginId,
    });
}
export async function installPluginFromFile(params) {
    const logger = params.logger ?? defaultLogger;
    const mode = params.mode ?? "install";
    const dryRun = params.dryRun ?? false;
    const filePath = resolveUserPath(params.filePath);
    if (!(await fileExists(filePath))) {
        return { ok: false, error: `file not found: ${filePath}` };
    }
    const extensionsDir = params.extensionsDir
        ? resolveUserPath(params.extensionsDir)
        : path.join(CONFIG_DIR, "extensions");
    await fs.mkdir(extensionsDir, { recursive: true });
    const base = path.basename(filePath, path.extname(filePath));
    const pluginId = base || "plugin";
    const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`);
    if (mode === "install" && (await fileExists(targetFile))) {
        return { ok: false, error: `插件已存在: ${targetFile} (请先删除)` };
    }
    if (dryRun) {
        return {
            ok: true,
            pluginId,
            targetDir: targetFile,
            manifestName: undefined,
            version: undefined,
            extensions: [path.basename(targetFile)],
        };
    }
    logger.info?.(`Installing to ${targetFile}…`);
    await fs.copyFile(filePath, targetFile);
    return {
        ok: true,
        pluginId,
        targetDir: targetFile,
        manifestName: undefined,
        version: undefined,
        extensions: [path.basename(targetFile)],
    };
}
export async function installPluginFromNpmSpec(params) {
    const logger = params.logger ?? defaultLogger;
    const timeoutMs = params.timeoutMs ?? 120_000;
    const mode = params.mode ?? "install";
    const dryRun = params.dryRun ?? false;
    const expectedPluginId = params.expectedPluginId;
    const spec = params.spec.trim();
    if (!spec)
        return { ok: false, error: "missing npm spec" };
    const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-npm-pack-"));
    logger.info?.(`Downloading ${spec}…`);
    const res = await runCommandWithTimeout(["npm", "pack", spec], {
        timeoutMs: Math.max(timeoutMs, 300_000),
        cwd: tmpDir,
        env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
        preferInheritStdin: false,
    });
    if (res.code !== 0) {
        return {
            ok: false,
            error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}`,
        };
    }
    const packed = (res.stdout || "")
        .split("\n")
        .map((l) => l.trim())
        .filter(Boolean)
        .pop();
    if (!packed) {
        return { ok: false, error: "npm pack produced no archive" };
    }
    const archivePath = path.join(tmpDir, packed);
    return await installPluginFromArchive({
        archivePath,
        extensionsDir: params.extensionsDir,
        timeoutMs,
        logger,
        mode,
        dryRun,
        expectedPluginId,
    });
}
export async function installPluginFromNpxScript(params) {
    const logger = params.logger ?? defaultLogger;
    const timeoutMs = params.timeoutMs ?? 300_000;
    const spec = params.spec.trim();
    if (!spec)
        return { ok: false, error: "missing npx spec" };
    // The spec is a URL to a self-installer CLI tgz (e.g. Feishu official installer).
    // Rather than running the installer (which requires `openclaw` in PATH), we inspect
    // the installer to extract the actual npm package name and install it directly.
    logger.info?.(`Fetching installer metadata from ${spec}…`);
    const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-npx-"));
    const installerPath = path.join(tmpDir, "installer.tgz");
    const extractDir = path.join(tmpDir, "installer");
    // Download the installer tgz
    const dlRes = await runCommandWithTimeout(["curl", "-fsSL", "-o", installerPath, spec], {
        timeoutMs,
        cwd: tmpDir,
    });
    if (dlRes.code !== 0) {
        return { ok: false, error: `download failed: ${dlRes.stderr.trim() || dlRes.stdout.trim()}` };
    }
    // Extract the installer and read its package.json to find the actual plugin package name
    await fs.mkdir(extractDir, { recursive: true });
    const tarRes = await runCommandWithTimeout(["tar", "-xzf", installerPath, "-C", extractDir], {
        timeoutMs,
        cwd: tmpDir,
    });
    if (tarRes.code !== 0) {
        return { ok: false, error: `extract failed: ${tarRes.stderr.trim()}` };
    }
    // The installer's install command embeds the plugin package name — find it from install.js
    let pluginNpmSpec = "";
    try {
        const installJs = await fs.readFile(path.join(extractDir, "package", "dist", "commands", "install.js"), "utf-8");
        // Look for: const PACKAGE_NAME = '@scope/package-name';
        const match = installJs.match(/const\s+PACKAGE_NAME\s*=\s*['"]([^'"]+)['"]/);
        if (match?.[1])
            pluginNpmSpec = match[1].trim();
    }
    catch {
        // ignore — fall through to error below
    }
    if (!pluginNpmSpec) {
        return { ok: false, error: "could not determine plugin package name from official installer" };
    }
    logger.info?.(`Installing official plugin ${pluginNpmSpec}…`);
    return await installPluginFromNpmSpec({
        spec: pluginNpmSpec,
        extensionsDir: params.extensionsDir,
        timeoutMs,
        logger,
        mode: "update",
        expectedPluginId: params.expectedPluginId,
    });
}
export async function installPluginFromPath(params) {
    const resolved = resolveUserPath(params.path);
    if (!(await fileExists(resolved))) {
        return { ok: false, error: `path not found: ${resolved}` };
    }
    const stat = await fs.stat(resolved);
    if (stat.isDirectory()) {
        return await installPluginFromDir({
            dirPath: resolved,
            extensionsDir: params.extensionsDir,
            timeoutMs: params.timeoutMs,
            logger: params.logger,
            mode: params.mode,
            dryRun: params.dryRun,
            expectedPluginId: params.expectedPluginId,
        });
    }
    const archiveKind = resolveArchiveKind(resolved);
    if (archiveKind) {
        return await installPluginFromArchive({
            archivePath: resolved,
            extensionsDir: params.extensionsDir,
            timeoutMs: params.timeoutMs,
            logger: params.logger,
            mode: params.mode,
            dryRun: params.dryRun,
            expectedPluginId: params.expectedPluginId,
        });
    }
    return await installPluginFromFile({
        filePath: resolved,
        extensionsDir: params.extensionsDir,
        logger: params.logger,
        mode: params.mode,
        dryRun: params.dryRun,
    });
}
