"use strict";
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getProjectInfo = exports.blacklistedInstalledPlugins = exports.alerts = exports.downloading = void 0;
exports.downloadPlugin = downloadPlugin;
exports.getRepoInfo = getRepoInfo;
exports.readGithubFile = readGithubFile;
exports.readOnlinePlugin = readOnlinePlugin;
exports.readOnlineCompatiblePlugin = readOnlineCompatiblePlugin;
exports.getFolder2repo = getFolder2repo;
exports.apiGithubPaginated = apiGithubPaginated;
exports.searchPlugins = searchPlugins;
const events_1 = __importDefault(require("./events"));
const misc_1 = require("./misc");
const plugins_1 = require("./plugins");
const apiMiddleware_1 = require("./apiMiddleware");
const lodash_1 = __importDefault(require("lodash"));
const const_1 = require("./const");
const promises_1 = require("fs/promises");
const path_1 = require("path");
const fs_1 = __importDefault(require("fs"));
const persistence_1 = require("./persistence");
const argv_1 = require("./argv");
const expiringCache_1 = require("./expiringCache");
const config_1 = require("./config");
const update_1 = require("./update");
const DIST_ROOT = 'dist';
exports.downloading = {};
function downloadProgress(repo, status) {
    if (status === undefined)
        delete exports.downloading[repo];
    else
        exports.downloading[repo] = status;
    events_1.default.emit('pluginDownload', { repo, status });
}
const branchCache = (0, expiringCache_1.expiringCache)(misc_1.DAY);
function getGithubDefaultBranch(repo) {
    if (!repo.includes('/'))
        throw 'malformed repo';
    return branchCache.try(repo, async () => {
        for (const b of ['main', 'master']) // try to not consume api quota
            if (await (0, misc_1.httpString)(`https://github.com/${repo}/raw/refs/heads/${b}/dist/plugin.js`, { method: 'HEAD', noRedirect: true }).then(() => 1, () => 0))
                return b;
        return (await getRepoInfo(repo))?.default_branch;
    });
}
async function downloadPlugin(repo, { branch = '', overwrite = false } = {}) {
    if (typeof repo !== 'string')
        repo = repo.main;
    if (exports.downloading[repo])
        throw new apiMiddleware_1.ApiError(const_1.HTTP_CONFLICT, "already downloading");
    const msg = await isPluginBlacklisted(repo); // check before downloading, in case other filters were passed somehow
    if (msg)
        throw new apiMiddleware_1.ApiError(const_1.HTTP_FORBIDDEN, "blacklisted: " + msg);
    console.log('Downloading plugin', repo);
    downloadProgress(repo, true);
    try {
        const pl = (0, plugins_1.findPluginByRepo)(repo);
        const customRepo = repo.includes('//');
        if (customRepo) { // custom repo
            if (!pl)
                throw new apiMiddleware_1.ApiError(const_1.HTTP_BAD_REQUEST, "bad repo");
            const customRepo = (pl.getData?.() || pl).repo;
            let url = customRepo?.zip;
            if (!url)
                throw new apiMiddleware_1.ApiError(const_1.HTTP_SERVER_ERROR, "bad plugin");
            if (!url.includes('//'))
                url = customRepo.web + url;
            return await go(url, pl?.id, customRepo.zipRoot ?? DIST_ROOT);
        }
        branch ||= await getGithubDefaultBranch(repo);
        const short = repo.split('/')[1]; // second part, repo without the owner
        if (!short)
            throw new apiMiddleware_1.ApiError(const_1.HTTP_BAD_REQUEST, "bad repo");
        const shortFolder = short.replace(/^hfs-/, '') || short;
        const folder = overwrite && pl?.id // use existing folder
            || (getFolder2repo().hasOwnProperty(shortFolder) ? repo.replace('/', '-') // longer form only if another plugin is using short form, to avoid overwriting
                : shortFolder);
        const GITHUB_ZIP_ROOT = short + '-' + branch; // GitHub puts everything within this folder
        return await go(`https://github.com/${repo}/archive/refs/heads/${branch}.zip`, folder, GITHUB_ZIP_ROOT + '/' + DIST_ROOT);
        async function go(url, folder, zipRoot) {
            const installPath = plugins_1.PATH + '/' + folder;
            await (0, promises_1.access)(installPath, fs_1.default.constants.W_OK) // early check for permission: access if it exists, mkdir+rmdir if it doesn't
                .catch(() => (0, promises_1.mkdir)(installPath, { recursive: true }).then(() => (0, promises_1.rmdir)(installPath)));
            const tempInstallPath = installPath + '-installing' + plugins_1.DISABLING_SUFFIX;
            const foldersToCopy = [
                zipRoot + '-' + process.platform + '-' + process.arch,
                zipRoot + '-' + process.platform,
                zipRoot,
            ].map(x => x + '/');
            // github zip doesn't have content-length, so we cannot produce progress event
            const stream = await (0, misc_1.httpStream)(url);
            await (0, misc_1.unzip)(stream, async (path) => {
                const folder = foldersToCopy.find(x => path.startsWith(x));
                if (!folder || path.endsWith('/'))
                    return false;
                let dest = path.slice(folder.length);
                dest = (0, path_1.join)(tempInstallPath, dest);
                return (0, promises_1.rm)(dest, { force: true }).then(() => dest, () => false);
            });
            if (!customRepo)
                try {
                    const mainFile = (0, path_1.join)(tempInstallPath, plugins_1.PLUGIN_MAIN_FILE);
                    const content = await (0, promises_1.readFile)(mainFile, 'utf8');
                    // force github plugins to have correct repo, in case it is missing, wrong, or just outdated after a rename
                    if (repo !== (0, plugins_1.parsePluginSource)('', content).repo) {
                        const correct = `exports.repo = ${JSON.stringify(repo)}\n`;
                        let newContent = content.replace(/exports.repo\s*=\s*\S*/g, correct);
                        if (newContent === content) // no change = the line is missing
                            newContent = correct + content;
                        await (0, promises_1.writeFile)(mainFile, newContent); // first, as our parsing will consider that (unlike javascript)
                    }
                }
                catch (e) { // don't abort the whole procedure just because of the check above. It should never fail, but a user reported a mysterious ENOENT on the readFile()
                    console.warn("Plugin's repo check failed", e);
                }
            // ready to replace
            const wasRunning = (0, plugins_1.isPluginRunning)(folder);
            if (wasRunning)
                await (0, plugins_1.stopPlugin)(folder); // stop old
            // move data, and consider late release of the resource, up to a few seconds
            await (0, misc_1.retry)(() => (0, promises_1.rename)((0, path_1.join)(installPath, plugins_1.STORAGE_FOLDER), (0, path_1.join)(tempInstallPath, plugins_1.STORAGE_FOLDER))
                .then(() => 1, e => e.code === 'ENOENT'));
            // delete old folder (if any), but it may fail in the presence of .node files, so we rename it first as a precaution (clearing require.cache doesn't help). Especially on Windows, it may be impossible to delete dll files until our process is terminated (in which case, retrying is useless).
            const deleteMe = installPath + plugins_1.DELETE_ME_SUFFIX;
            await (0, misc_1.retry)(() => (0, promises_1.rename)(installPath, deleteMe).then(() => 1, (e) => {
                if (e.code === 'ENOENT')
                    return 1; // nothing to do
                console.warn("Error renaming old plugin folder:", String(e));
            }));
            await (0, misc_1.retry)(() => (0, promises_1.rm)(deleteMe, { recursive: true, force: true /*ignore ENOENT*/ }).then(() => 1, e => {
                console.warn("Error deleting old plugin folder:", String(e));
            }));
            // final replace
            await (0, promises_1.rename)(tempInstallPath, installPath)
                .catch(e => { throw e.code !== 'ENOENT' ? e : new apiMiddleware_1.ApiError(const_1.HTTP_NOT_ACCEPTABLE, "missing main file"); });
            if (wasRunning)
                if (await (0, misc_1.waitFor)(() => (0, plugins_1.getPluginInfo)(folder), { timeout: 10_000 }))
                    void (0, plugins_1.startPlugin)(folder) // don't wait, in case it fails to start. We still use startPlugin instead of enablePlugin, as it will take care of disabling other themes.
                        .catch(e => console.warn(String(e)));
            events_1.default.emit('pluginDownloaded', { id: folder, repo });
            return folder;
        }
    }
    finally {
        downloadProgress(repo, undefined);
    }
}
function getRepoInfo(id) {
    return apiGithub('repos/' + id);
}
function readGithubFile(uri) {
    return (0, misc_1.httpString)('https://raw.githubusercontent.com/' + uri);
}
async function readOnlinePlugin(repo, branch = '') {
    if (typeof repo !== 'string') { // non-github plugin
        const folder = lodash_1.default.findKey(getFolder2repo(), x => x === repo);
        if (!folder)
            throw Error();
        const pl = (0, plugins_1.getPluginInfo)(folder);
        let { main } = pl.repo;
        if (!main)
            throw Error("missing repo.main");
        if (!main.includes('//'))
            main = pl.repo.web + main;
        return (0, plugins_1.parsePluginSource)(main, await (0, misc_1.httpString)(main)); // use 'repo' as 'id' client-side
    }
    branch ||= await getGithubDefaultBranch(repo);
    const res = await readGithubFile(`${repo}/${branch}/${DIST_ROOT}/${plugins_1.PLUGIN_MAIN_FILE}`);
    const pl = (0, plugins_1.parsePluginSource)(repo, res); // use 'repo' as 'id' client-side
    pl.branch = branch;
    return pl;
}
async function readOnlineCompatiblePlugin(repo, branch = '') {
    const pl = await readOnlinePlugin(repo, branch);
    if (!pl?.apiRequired)
        return; // mandatory field
    if (!pl.badApi)
        return pl;
    // we try other branches (starting with 'api')
    const res = await apiGithub('repos/' + repo + '/branches');
    const branches = res.map((x) => x?.name)
        .filter((x) => typeof x === 'string' && x.startsWith('api'))
        .sort().reverse();
    for (const branch of branches) {
        const pl = await readOnlinePlugin(repo, branch);
        if (!pl)
            continue;
        if (!pl.apiRequired)
            pl.badApi = '-';
        if (!pl.badApi)
            return pl;
    }
}
function getFolder2repo() {
    const ret = Object.fromEntries((0, plugins_1.getInactivePlugins)().map(x => [x.id, x.repo]));
    Object.assign(ret, Object.fromEntries((0, plugins_1.mapPlugins)(x => [x.id, x.getData().repo])));
    return ret;
}
async function apiGithub(uri) {
    return (0, misc_1.httpString)('https://api.github.com/' + uri, {
        headers: {
            'User-Agent': 'HFS',
            Accept: 'application/vnd.github.v3+json',
        }
    }).then(JSON.parse, e => {
        // https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limiting
        throw e.message === '403' ? Error('github_quota')
            : e;
    });
}
async function* apiGithubPaginated(uri) {
    uri += uri.includes('?') ? '&' : '?';
    const PAGE_SIZE = 100;
    let page = 1;
    let n = 0;
    try {
        while (1) {
            const res = await apiGithub(uri + `page=${page++}&per_page=${PAGE_SIZE}`);
            const a = res.items || res; // "search/repositories" returns an object, while "releases" returns simply an array
            for (const x of a)
                yield x;
            const now = a.length;
            n += now;
            if (!now || n >= res.total_count)
                break;
        }
    }
    catch (e) {
        if (e.message !== '422') // for some strange reason github api is returning this error if we search repos for a missing user, instead of empty set
            throw e;
    }
}
async function isPluginBlacklisted(repo) {
    return (0, exports.getProjectInfo)().then(x => x?.repo_blacklist?.[repo]?.message || '', () => undefined);
}
async function searchPlugins(text = '', { skipRepos = [''] } = {}) {
    const seen = new Set();
    return new misc_1.AsapStream(pluginPromises());
    async function* pluginPromises() {
        // github doesn't allow complex search, so we have to do it multiple times and merge the results
        const searches = [
            ...text.split(' ').filter(Boolean).slice(0, 2).map(x => 'user:' + encodeURI(x)), // first 2 words can be the author of the plugin
            encodeURI(text), // search elsewhere, and results after the author search
        ];
        for (const term of searches) {
            for await (const it of apiGithubPaginated(`search/repositories?q=topic:hfs-plugin+${term}`)) {
                const repo = it.full_name;
                if (!repo || seen.has(repo)) // avoid duplicates, as we search multiple times
                    continue;
                seen.add(repo);
                if (skipRepos.includes(repo))
                    continue;
                yield (async () => {
                    if (await isPluginBlacklisted(repo))
                        return;
                    const pl = await readOnlineCompatiblePlugin(repo, it.default_branch).catch(() => undefined);
                    if (!pl)
                        return;
                    Object.assign(pl, {
                        repo,
                        downloading: exports.downloading[repo],
                        license: it.license?.spdx_id,
                    }, lodash_1.default.pick(it, ['pushed_at', 'stargazers_count', 'default_branch']));
                    return pl;
                })();
            }
        }
    }
}
persistence_1.storedMap.ready().then(() => persistence_1.storedMap.del('alerts')); // remove legacy
const cachedCentralInfo = persistence_1.storedMap.singleSync('cachedCentralInfo', ''); // persisting it could also be useful for no-internet instances, so that you can provide a fresher copy
exports.blacklistedInstalledPlugins = [];
// centralized hosted information, to be used as little as possible
const FN = 'central.json';
const builtInJson = fs_1.default.readFileSync((0, path_1.join)(__dirname, '..', FN), 'utf8');
const branch = const_1.RUNNING_BETA ? const_1.VERSION.split('.')[1] : 'main';
exports.getProjectInfo = (0, misc_1.debounceAsync)(async () => {
    const txt = argv_1.argv.central === false ? builtInJson
        : await readGithubFile(`${const_1.HFS_REPO}/${branch}/${FN}`)
            .catch(() => const_1.RUNNING_BETA ? readGithubFile(`${const_1.HFS_REPO}/main/${FN}`) : '') // for beta versions, try again with 'main'
            .catch(() => '');
    let obj = (0, misc_1.tryJson)(txt);
    await cachedCentralInfo.ready();
    if (obj) {
        cachedCentralInfo.set(obj);
        obj = { ...obj }; // so that later modifications are not done on the cached object
    }
    else
        obj = { ...cachedCentralInfo.get() || JSON.parse(builtInJson) }; // fall back to built-in
    // merge byVersions info in the main object but collect alerts separately to preserve multiple instances
    const newAlerts = [obj.alert];
    for (const [ver, more] of Object.entries((0, misc_1.popKey)(obj, 'byVersion') || {}))
        if (const_1.VERSION.match(new RegExp(ver))) {
            newAlerts.push(more.alert);
            Object.assign(obj, more);
        }
    lodash_1.default.remove(newAlerts, x => !x);
    if (!lodash_1.default.isEqual(exports.alerts, newAlerts)) {
        exports.alerts = newAlerts;
        if (newAlerts.length)
            void (0, update_1.checkForUpdates)(); // with new alerts, is best to have fresh updates info
        for (const a of newAlerts) {
            console.log("ALERT:", a);
            events_1.default.emit('alert', { message: a });
        }
    }
    const black = (0, misc_1.onlyTruthy)(Object.keys(obj.repo_blacklist || {}).map(plugins_1.findPluginByRepo));
    exports.blacklistedInstalledPlugins = (0, misc_1.onlyTruthy)(black.map(x => lodash_1.default.isString(x.repo) && x.repo));
    if (black.length) {
        console.log("Blacklisted plugins found:", black.join(', '));
        for (const p of black)
            (0, plugins_1.enablePlugin)(p.id, false);
    }
    return obj;
}, { retain: misc_1.HOUR, retainFailure: 60_000 });
// refresh of alerts and blacklist happens early without stalling startup
config_1.configReady.then(exports.getProjectInfo);
