import { Config, Errors, ux } from '@oclif/core';
import { bold } from 'ansis';
import makeDebug from 'debug';
import { spawn } from 'node:child_process';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, parse, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gt, valid, validRange } from 'semver';
import { NPM } from './npm.js';
import { uniqWith } from './util.js';
import { Yarn } from './yarn.js';
const initPJSON = {
    dependencies: {},
    oclif: { plugins: [], schema: 1 },
    private: true,
};
async function fileExists(filePath) {
    try {
        await access(filePath);
        return true;
    }
    catch {
        return false;
    }
}
function dedupePlugins(plugins) {
    return uniqWith(plugins, (a, b) => a.name === b.name || (a.type === 'link' && b.type === 'link' && a.root === b.root));
}
function extractIssuesLocation(bugs, repository) {
    if (bugs) {
        return typeof bugs === 'string' ? bugs : bugs.url;
    }
    if (repository) {
        return typeof repository === 'string' ? repository : repository.url.replace('git+', '').replace('.git', '');
    }
}
function notifyUser(plugin, output) {
    const containsWarnings = [...output.stdout, ...output.stderr].some((l) => l.includes('npm WARN'));
    if (containsWarnings) {
        ux.stderr(bold.yellow(`\nThese warnings can only be addressed by the owner(s) of ${plugin.name}.`));
        if (plugin.pjson.bugs || plugin.pjson.repository) {
            ux.stderr(`We suggest that you create an issue at ${extractIssuesLocation(plugin.pjson.bugs, plugin.pjson.repository)} and ask the plugin owners to address them.\n`);
        }
    }
}
export default class Plugins {
    config;
    npm;
    debug;
    logLevel;
    constructor(options) {
        this.config = options.config;
        this.debug = makeDebug('@oclif/plugin-plugins');
        this.logLevel = options.logLevel ?? 'notice';
        this.npm = new NPM({
            config: this.config,
            logLevel: this.logLevel,
        });
    }
    get pjsonPath() {
        return join(this.config.dataDir, 'package.json');
    }
    async add(...plugins) {
        const pjson = await this.pjson();
        const mergedPlugins = [...(pjson.oclif.plugins || []), ...plugins];
        await this.savePJSON({
            ...pjson,
            oclif: {
                ...pjson.oclif,
                plugins: dedupePlugins(mergedPlugins),
            },
        });
    }
    friendlyName(name) {
        const { pluginPrefix, scope } = this.config.pjson.oclif;
        if (!scope)
            return name;
        const match = name.match(`@${scope}/${pluginPrefix ?? 'plugin'}-(.+)`);
        return match?.[1] ?? name;
    }
    async hasPlugin(name) {
        const list = await this.list();
        const friendlyName = this.friendlyName(name);
        const unfriendlyName = this.unfriendlyName(name) ?? name;
        return (list.find((p) => this.friendlyName(p.name) === friendlyName) ?? // friendly
            list.find((p) => this.unfriendlyName(p.name) === unfriendlyName) ?? // unfriendly
            list.find((p) => p.type === 'link' && resolve(p.root) === resolve(name)) ?? // link
            false);
    }
    async install(name, { force = false, tag = 'latest' } = {}) {
        await this.maybeCleanUp();
        try {
            this.debug(`installing plugin ${name}`);
            const options = { cwd: this.config.dataDir, logLevel: this.logLevel, prod: true };
            await this.ensurePJSON();
            let plugin;
            const args = force ? ['--force'] : [];
            if (name.includes(':')) {
                // url
                const url = name;
                const output = await this.npm.install([...args, url], options);
                const { dependencies } = await this.pjson();
                const { default: npa } = await import('npm-package-arg');
                const normalizedUrl = npa(url);
                const matches = Object.entries(dependencies ?? {}).find(([, npmVersion]) => {
                    const normalized = npa(npmVersion);
                    // for local file paths
                    if (normalized.type === 'file' && normalized.raw) {
                        return parse(url).base === parse(normalized.raw).base;
                    }
                    // for hosted git urls
                    return (normalized.hosted?.type === normalizedUrl.hosted?.type &&
                        normalized.hosted?.user === normalizedUrl.hosted?.user &&
                        normalized.hosted?.project === normalizedUrl.hosted?.project);
                });
                const installedPluginName = matches?.[0];
                if (!installedPluginName)
                    throw new Errors.CLIError(`Could not find plugin name for ${url}`);
                const root = join(this.config.dataDir, 'node_modules', installedPluginName);
                plugin = await Config.load({
                    devPlugins: false,
                    name: installedPluginName,
                    root,
                    userPlugins: false,
                });
                notifyUser(plugin, output);
                this.isValidPlugin(plugin);
                await this.add({ name: installedPluginName, type: 'user', url });
                // Check that the prepare script produced all the expected files
                // If it didn't, it might be because the plugin doesn't have a prepare
                // script that compiles the plugin from source.
                const safeToNotExist = new Set(['npm-shrinkwrap.json', 'oclif.lock', 'oclif.manifest.json']);
                const files = (plugin.pjson.files ?? [])
                    .map((f) => join(root, f))
                    .filter((f) => !safeToNotExist.has(basename(f)));
                this.debug(`checking for existence of files: ${files.join(', ')}`);
                const results = Object.fromEntries(await Promise.all(files?.map(async (f) => [f, await fileExists(f)]) ?? []));
                this.debug(results);
                if (!Object.values(results).every(Boolean)) {
                    ux.warn(`This plugin may not work as expected because the prepare script did not produce all the expected files.`);
                }
            }
            else {
                // npm
                const range = validRange(tag);
                const unfriendly = this.unfriendlyName(name);
                if (unfriendly && (await this.npmHasPackage(unfriendly))) {
                    name = unfriendly;
                }
                // validate that the package name exists in the npm registry before installing
                await this.npmHasPackage(name, true);
                const output = await this.npm.install([...args, `${name}@${tag}`], options);
                this.debug(`loading plugin ${name}...`);
                plugin = await Config.load({
                    devPlugins: false,
                    name,
                    root: join(this.config.dataDir, 'node_modules', name),
                    userPlugins: false,
                });
                this.debug(`finished loading plugin ${name} at root ${plugin.root}`);
                notifyUser(plugin, output);
                this.isValidPlugin(plugin);
                await this.add({ name, tag: range ?? tag, type: 'user' });
            }
            await rm(join(this.config.dataDir, 'yarn.lock'), { force: true });
            return plugin;
        }
        catch (error) {
            this.debug('error installing plugin:', error);
            await this.uninstall(name).catch((error) => this.debug(error));
            if (String(error).includes('EACCES')) {
                throw new Errors.CLIError(error, {
                    suggestions: [
                        `Plugin failed to install because of a permissions error.\nDoes your current user own the directory ${this.config.dataDir}?`,
                    ],
                });
            }
            throw error;
        }
    }
    async link(p, { install }) {
        const c = await Config.load(resolve(p));
        this.isValidPlugin(c);
        if (install) {
            if (await fileExists(join(c.root, 'yarn.lock'))) {
                this.debug('installing dependencies with yarn');
                const yarn = new Yarn({ config: this.config, logLevel: this.logLevel });
                ux.warn(`The ${ux.colorize('dim', 'plugins link')} command will no longer auto install dependencies in future versions. Please run ${ux.colorize('dim', 'yarn')} manually in the plugin directory before linking.`);
                await yarn.install([], {
                    cwd: c.root,
                    logLevel: this.logLevel,
                });
            }
            else if (await fileExists(join(c.root, 'package-lock.json'))) {
                this.debug('installing dependencies with npm');
                await this.npm.install([], {
                    cwd: c.root,
                    logLevel: this.logLevel,
                    prod: false,
                });
            }
            else if (await fileExists(join(c.root, 'pnpm-lock.yaml'))) {
                ux.warn(`pnpm is not supported for installing after a link. The link succeeded, but you may need to run 'pnpm install' in ${c.root}.`);
            }
            else {
                ux.warn(`No lockfile found in ${c.root}. The link succeeded, but you may need to install the dependencies in your project.`);
            }
        }
        await this.add({ name: c.name, root: c.root, type: 'link' });
        return c;
    }
    async list() {
        const pjson = await this.pjson();
        return pjson.oclif.plugins;
    }
    async maybeUnfriendlyName(name) {
        await this.ensurePJSON();
        const unfriendly = this.unfriendlyName(name);
        this.debug(`checking registry for expanded package name ${unfriendly}`);
        if (unfriendly && (await this.npmHasPackage(unfriendly))) {
            return unfriendly;
        }
        this.debug(`expanded package name ${unfriendly} not found, using given package name ${name}`);
        return name;
    }
    async pjson() {
        const pjson = await this.readPJSON();
        const plugins = pjson ? normalizePlugins(pjson.oclif.plugins) : [];
        return {
            ...initPJSON,
            ...pjson,
            oclif: {
                ...initPJSON.oclif,
                ...pjson?.oclif,
                plugins,
            },
        };
    }
    async remove(name) {
        const pjson = await this.pjson();
        if (pjson.dependencies)
            delete pjson.dependencies[name];
        await this.savePJSON({
            ...pjson,
            oclif: {
                ...pjson.oclif,
                plugins: pjson.oclif.plugins.filter((p) => p.name !== name),
            },
        });
    }
    unfriendlyName(name) {
        if (name.includes('@'))
            return;
        const { pluginPrefix, scope } = this.config.pjson.oclif;
        if (!scope)
            return;
        return `@${scope}/${pluginPrefix ?? 'plugin'}-${name}`;
    }
    async uninstall(name) {
        try {
            const pjson = await this.pjson();
            if ((pjson.oclif.plugins ?? []).some((p) => typeof p === 'object' && p.type === 'user' && p.name === name)) {
                await this.npm.uninstall([name], {
                    cwd: this.config.dataDir,
                    logLevel: this.logLevel,
                });
            }
        }
        catch (error) {
            ux.warn(error);
        }
        finally {
            await this.remove(name);
        }
    }
    async update() {
        let plugins = (await this.list()).filter((p) => p.type === 'user');
        if (plugins.length === 0)
            return;
        await this.maybeCleanUp();
        // migrate deprecated plugins
        const aliases = this.config.pjson.oclif.aliases || {};
        for (const [name, to] of Object.entries(aliases)) {
            const plugin = plugins.find((p) => p.name === name);
            if (!plugin)
                continue;
            // eslint-disable-next-line no-await-in-loop
            if (to)
                await this.install(to);
            // eslint-disable-next-line no-await-in-loop
            await this.uninstall(name);
            plugins = plugins.filter((p) => p.name !== name);
        }
        const urlPlugins = plugins.filter((p) => Boolean(p.url));
        if (urlPlugins.length > 0) {
            await this.npm.update(urlPlugins.map((p) => p.name), {
                cwd: this.config.dataDir,
                logLevel: this.logLevel,
            });
        }
        const npmPlugins = plugins.filter((p) => !p.url);
        const jitPlugins = this.config.pjson.oclif.jitPlugins ?? {};
        const modifiedPlugins = [];
        if (npmPlugins.length > 0) {
            await this.npm.install(npmPlugins.map((p) => {
                // a not valid tag indicates that it's a dist-tag like 'latest'
                if (!valid(p.tag))
                    return `${p.name}@${p.tag}`;
                if (p.tag && valid(p.tag) && jitPlugins[p.name] && gt(p.tag, jitPlugins[p.name])) {
                    // The user has installed a version of the JIT plugin that is newer than the one
                    // specified by the root plugin's JIT configuration. In this case, we want to
                    // keep the version installed by the user.
                    return `${p.name}@${p.tag}`;
                }
                const tag = jitPlugins[p.name] ?? p.tag;
                modifiedPlugins.push({ ...p, tag });
                return `${p.name}@${tag}`;
            }), { cwd: this.config.dataDir, logLevel: this.logLevel, prod: true });
        }
        await this.add(...modifiedPlugins);
    }
    async ensurePJSON() {
        if (!(await fileExists(this.pjsonPath))) {
            this.debug(`creating ${this.pjsonPath} with pjson: ${JSON.stringify(initPJSON, null, 2)}`);
            await this.savePJSON(initPJSON);
        }
    }
    isValidPlugin(p) {
        if (p.valid)
            return true;
        if (this.config.plugins.get('@oclif/plugin-legacy') ||
            // @ts-expect-error because _base is private
            p._base.includes('@oclif/plugin-legacy')) {
            return true;
        }
        throw new Errors.CLIError('plugin is invalid', {
            suggestions: [
                'Plugin failed to install because it does not appear to be a valid CLI plugin.\nIf you are sure it is, contact the CLI developer noting this error.',
            ],
        });
    }
    async maybeCleanUp() {
        // If the yarn.lock exists, then we assume that the last install was done with an older major
        // version of plugin-plugins that used yarn (v1). In this case, we want to remove the yarn.lock
        // and node_modules to ensure a clean install or update.
        if (await fileExists(join(this.config.dataDir, 'yarn.lock'))) {
            try {
                this.debug('Found yarn.lock! Removing yarn.lock and node_modules...');
                await Promise.all([
                    rename(join(this.config.dataDir, 'node_modules'), join(this.config.dataDir, 'node_modules.old')),
                    rm(join(this.config.dataDir, 'yarn.lock'), { force: true }),
                ]);
                // Spawn a new process so that node_modules can be deleted asynchronously.
                const rmScript = join(dirname(fileURLToPath(import.meta.url)), 'rm.js');
                this.debug(`spawning ${rmScript} to remove node_modules.old`);
                spawn(process.argv[0], [rmScript, join(this.config.dataDir, 'node_modules.old')], {
                    detached: true,
                    stdio: 'ignore',
                    ...(this.config.windows ? { shell: true } : {}),
                }).unref();
            }
            catch (error) {
                this.debug('Error cleaning up yarn.lock and node_modules:', error);
            }
        }
    }
    async npmHasPackage(name, throwOnNotFound = false) {
        try {
            await this.npm.view([name], {
                cwd: this.config.dataDir,
                logLevel: this.logLevel,
            });
            this.debug(`Found ${name} in the registry.`);
            return true;
        }
        catch (error) {
            this.debug(error);
            if (throwOnNotFound)
                throw new Errors.CLIError(`${name} does not exist in the registry.`);
            return false;
        }
    }
    async readPJSON() {
        try {
            return JSON.parse(await readFile(this.pjsonPath, 'utf8'));
        }
        catch (error) {
            this.debug(error);
            const err = error;
            if (err.code !== 'ENOENT')
                process.emitWarning(err);
        }
    }
    async savePJSON(pjson) {
        await mkdir(dirname(this.pjsonPath), { recursive: true });
        await writeFile(this.pjsonPath, JSON.stringify({ name: this.config.name, ...pjson }, null, 2));
    }
}
// if the plugin is a simple string, convert it to an object
const normalizePlugins = (input) => {
    const normalized = (input ?? []).map((p) => typeof p === 'string'
        ? {
            name: p,
            tag: 'latest',
            type: 'user',
        }
        : p);
    return dedupePlugins(normalized);
};
