import { readdir, stat } from "fs/promises";
import { basename, dirname, join, resolve, sep } from "path";
import { inspect } from "util";
import xmlrpc from "xmlrpc";
import { humanReadableSize } from "@cross-seed/shared/utils";
import { InjectionResult, TORRENT_TAG, USER_AGENT, } from "../constants.js";
import { db } from "../db.js";
import { CrossSeedError } from "../errors.js";
import { Label, logger } from "../logger.js";
import { Metafile } from "../parseTorrent.js";
import { resultOf, resultOfErr } from "../Result.js";
import { getRuntimeConfig } from "../runtimeConfig.js";
import { createSearcheeFromDB, parseTitle, updateSearcheeClientDB, } from "../searchee.js";
import { extractCredentialsFromUrl, fromBatches, isTruthy, mapAsync, sanitizeInfoHash, wait, } from "../utils.js";
import { shouldResumeFromNonRelevantFiles, clientSearcheeModified, getMaxRemainingBytes, getResumeStopTime, organizeTrackers, resumeErrSleepTime, resumeSleepTime, shouldRecheck, } from "./TorrentClient.js";
const COULD_NOT_FIND_INFO_HASH = "Could not find info-hash.";
async function createLibTorrentResumeTree(meta, basePath) {
    async function getFileResumeData(file) {
        const filePathWithoutFirstSegment = file.path
            .split(sep)
            .slice(1)
            .join(sep);
        const resolvedFilePath = resolve(basePath, filePathWithoutFirstSegment);
        const fileStat = await stat(resolvedFilePath).catch(() => ({ isFile: () => false }));
        if (!fileStat.isFile() || fileStat.size !== file.length) {
            return null;
        }
        return {
            completed: Math.ceil(file.length / meta.pieceLength),
            mtime: Math.trunc(fileStat.mtimeMs / 1000),
            priority: 1,
        };
    }
    const fileResumes = await mapAsync(meta.files, getFileResumeData);
    return {
        bitfield: Math.ceil(meta.length / meta.pieceLength),
        files: fileResumes.filter(isTruthy),
    };
}
export default class RTorrent {
    client;
    clientHost;
    clientPriority;
    clientType = Label.RTORRENT;
    readonly;
    label;
    batchSize = 500;
    constructor(url, clientHost, priority, readonly) {
        this.clientHost = clientHost;
        this.clientPriority = priority;
        this.readonly = readonly;
        this.label = `${this.clientType}@${this.clientHost}`;
        const { href, username, password } = extractCredentialsFromUrl(url).unwrapOrThrow(new CrossSeedError(`[${this.label}] rTorrent url must be percent-encoded`));
        const clientCreator = new URL(href).protocol === "https:"
            ? xmlrpc.createSecureClient
            : xmlrpc.createClient;
        const shouldUseAuth = Boolean(username && password);
        this.client = clientCreator({
            url: href,
            basic_auth: shouldUseAuth
                ? { user: username, pass: password }
                : undefined,
            headers: {
                "User-Agent": USER_AGENT,
            },
        });
    }
    async methodCallP(method, args) {
        const msg = `Calling method ${method} with params ${inspect(args, { depth: null, compact: true })}`;
        const message = msg.length > 1000 ? `${msg.slice(0, 1000)}...` : msg;
        logger.verbose({ label: this.label, message });
        return new Promise((resolve, reject) => {
            this.client.methodCall(method, args, (err, data) => {
                if (err)
                    return reject(err);
                return resolve(data);
            });
        });
    }
    async isTorrentInClient(inputHash) {
        const infoHash = inputHash.toLowerCase();
        try {
            const downloadList = await this.methodCallP("download_list", []);
            for (const hash of downloadList) {
                if (hash.toLowerCase() === infoHash)
                    return resultOf(true);
            }
            return resultOf(false);
        }
        catch (e) {
            return resultOfErr(e);
        }
    }
    async checkOriginalTorrent(infoHash, options) {
        const hash = infoHash.toUpperCase();
        let response;
        const args = [
            [
                {
                    methodName: "d.name",
                    params: [hash],
                },
                {
                    methodName: "d.directory",
                    params: [hash],
                },
                {
                    methodName: "d.left_bytes",
                    params: [hash],
                },
                {
                    methodName: "d.hashing",
                    params: [hash],
                },
                {
                    methodName: "d.complete",
                    params: [hash],
                },
                {
                    methodName: "d.is_multi_file",
                    params: [hash],
                },
                {
                    methodName: "d.is_active",
                    params: [hash],
                },
            ],
        ];
        try {
            response = await this.methodCallP("system.multicall", args);
        }
        catch (e) {
            logger.debug({ label: this.label, message: e });
            return resultOfErr("FAILURE");
        }
        function isFault(response) {
            return "faultString" in response[0];
        }
        try {
            if (isFault(response)) {
                if (response[0].faultString === COULD_NOT_FIND_INFO_HASH) {
                    return resultOfErr("NOT_FOUND");
                }
                else {
                    throw new Error("Unknown rTorrent fault while checking original torrent");
                }
            }
            const [[name], [directoryBase], [bytesLeftStr], [hashingStr], [isCompleteStr], [isMultiFileStr], [isActiveStr],] = response;
            const isComplete = Boolean(Number(isCompleteStr));
            if (options.onlyCompleted && !isComplete) {
                return resultOfErr("TORRENT_NOT_COMPLETE");
            }
            return resultOf({
                name,
                directoryBase,
                bytesLeft: Number(bytesLeftStr),
                hashing: Number(hashingStr),
                isMultiFile: Boolean(Number(isMultiFileStr)),
                isActive: Boolean(Number(isActiveStr)),
            });
        }
        catch (e) {
            logger.error({ label: this.label, message: e });
            logger.debug("Failure caused by server response below:");
            logger.debug(inspect(response));
            return resultOfErr("FAILURE");
        }
    }
    async getDownloadLocation(meta, searchee, options) {
        if (options.destinationDir) {
            // resolve to absolute because we send the path to rTorrent
            const basePath = resolve(options.destinationDir, meta.name);
            const directoryBase = meta.isSingleFileTorrent
                ? options.destinationDir
                : basePath;
            return resultOf({
                downloadDir: options.destinationDir,
                basePath,
                directoryBase,
            });
        }
        else {
            const result = await this.checkOriginalTorrent(searchee.infoHash, {
                onlyCompleted: options.onlyCompleted,
            });
            return result.mapOk(({ directoryBase }) => ({
                directoryBase,
                downloadDir: meta.isSingleFileTorrent
                    ? directoryBase
                    : dirname(directoryBase),
                basePath: meta.isSingleFileTorrent
                    ? join(directoryBase, searchee.name)
                    : directoryBase,
            }));
        }
    }
    async validateConnection() {
        try {
            await this.methodCallP("session.name", []);
        }
        catch (e) {
            logger.debug({ label: this.label, message: e });
            throw new CrossSeedError(`[${this.label}] Failed to reach rTorrent at ${this.clientHost}: ${e.message}`);
        }
        logger.info({
            label: this.label,
            message: `Logged in successfully${this.readonly ? " (readonly)" : ""}`,
        });
    }
    async validateConfig() {
        const { torrentDir } = getRuntimeConfig();
        try {
            await this.methodCallP("download_list", []);
        }
        catch (e) {
            logger.debug({ label: this.label, message: e });
            throw new CrossSeedError(`[${this.label}] Failed to reach rTorrent at ${this.clientHost}: ${e.message}`);
        }
        logger.info({
            label: this.label,
            message: `Logged in successfully${this.readonly ? " (readonly)" : ""}`,
        });
        if (!torrentDir)
            return;
        if (!(await readdir(torrentDir)).some((f) => f.endsWith("_resume"))) {
            throw new CrossSeedError(`[${this.label}] Invalid torrentDir, if no torrents are in client set to null for now: https://www.cross-seed.org/docs/basics/options#torrentdir`);
        }
    }
    async getDownloadDir(meta, options) {
        const existsRes = await this.isTorrentInClient(meta.infoHash);
        if (existsRes.isErr())
            return resultOfErr("UNKNOWN_ERROR");
        if (!existsRes.unwrap())
            return resultOfErr("NOT_FOUND");
        const result = await this.checkOriginalTorrent(meta.infoHash, options);
        return result
            .mapOk(({ directoryBase, isMultiFile }) => {
            return isMultiFile ? dirname(directoryBase) : directoryBase;
        })
            .mapErr((error) => (error === "FAILURE" ? "UNKNOWN_ERROR" : error));
    }
    async getAllDownloadDirs(options) {
        const hashes = await this.methodCallP("download_list", []);
        function isFault(response) {
            return "faultString" in response[0];
        }
        let numMethods = 0;
        const results = await fromBatches(hashes, async (batch) => {
            const args = [
                batch.flatMap((hash) => {
                    const arg = [
                        {
                            methodName: "d.directory",
                            params: [hash],
                        },
                        {
                            methodName: "d.is_multi_file",
                            params: [hash],
                        },
                        {
                            methodName: "d.complete",
                            params: [hash],
                        },
                    ];
                    numMethods = arg.length;
                    return arg;
                }),
            ];
            try {
                const res = await this.methodCallP("system.multicall", args);
                if (isFault(res)) {
                    logger.error({
                        label: this.label,
                        message: "Fault while getting download directories for all torrents",
                    });
                    logger.debug(inspect(res));
                    return [];
                }
                return res;
            }
            catch (e) {
                logger.error({
                    label: this.label,
                    message: `Failed to get download directories for all torrents: ${e.message}`,
                });
                logger.debug(e);
                return [];
            }
        }, { batchSize: this.batchSize });
        if (!results.length || results.length !== hashes.length * numMethods) {
            logger.error({
                label: this.label,
                message: `Unexpected number of results: ${results.length} for ${hashes.length} hashes`,
            });
            return new Map();
        }
        try {
            return hashes.reduce((infoHashSavePathMap, hash, index) => {
                const directory = results[index * numMethods][0];
                const isMultiFile = Boolean(Number(results[index * numMethods + 1][0]));
                const isComplete = Boolean(Number(results[index * numMethods + 2][0]));
                if (!options.onlyCompleted || isComplete) {
                    infoHashSavePathMap.set(hash, isMultiFile ? dirname(directory) : directory);
                }
                return infoHashSavePathMap;
            }, new Map());
        }
        catch (e) {
            logger.error({
                label: this.label,
                message: `Error parsing response for all torrents: ${e.message}`,
            });
            logger.debug(e);
            return new Map();
        }
    }
    async isTorrentComplete(infoHash) {
        try {
            const response = await this.methodCallP("d.complete", [
                infoHash,
            ]);
            if (response.length === 0) {
                return resultOfErr("NOT_FOUND");
            }
            return resultOf(Boolean(Number(response[0])));
        }
        catch {
            return resultOfErr("NOT_FOUND");
        }
    }
    async isTorrentChecking(infoHash) {
        try {
            const response = await this.methodCallP("d.hashing", [
                infoHash,
            ]);
            if (response.length === 0) {
                return resultOfErr("NOT_FOUND");
            }
            return resultOf(Boolean(Number(response[0])));
        }
        catch {
            return resultOfErr("NOT_FOUND");
        }
    }
    async getAllTorrents() {
        const hashes = await this.methodCallP("download_list", []);
        function isFault(response) {
            return "faultString" in response[0];
        }
        const results = await fromBatches(hashes, async (batch) => {
            const args = [
                batch.map((hash) => {
                    return {
                        methodName: "d.custom1",
                        params: [hash],
                    };
                }),
            ];
            try {
                const res = await this.methodCallP("system.multicall", args);
                if (isFault(res)) {
                    logger.error({
                        label: this.label,
                        message: "Fault while getting torrent info for all torrents",
                    });
                    logger.debug(inspect(res));
                    return [];
                }
                return res;
            }
            catch (e) {
                logger.error({
                    label: this.label,
                    message: `Failed to get torrent info for all torrents: ${e.message}`,
                });
                logger.debug(e);
                return [];
            }
        }, { batchSize: this.batchSize });
        if (results.length !== hashes.length) {
            logger.error({
                label: this.label,
                message: `Unexpected number of results: ${results.length} for ${hashes.length} hashes`,
            });
            return [];
        }
        try {
            // response: [ [tag1], [tag2], ... ], assuming infoHash order is preserved
            return hashes.map((hash, index) => ({
                infoHash: hash.toLowerCase(),
                tags: results[index].length !== 1
                    ? results[index]
                    : results[index][0].length
                        ? decodeURIComponent(results[index][0])
                            .split(",")
                            .map((tag) => tag.trim())
                        : [],
            }));
        }
        catch (e) {
            logger.error({
                label: this.label,
                message: `Error parsing response for all torrents: ${e.message}`,
            });
            logger.debug(e);
            return [];
        }
    }
    async getClientSearchees(options) {
        const searchees = [];
        const newSearchees = [];
        const hashes = await this.methodCallP("download_list", []);
        function isFault(response) {
            return "faultString" in response[0];
        }
        let numMethods = 0;
        const results = await fromBatches(hashes, async (batch) => {
            const args = [
                batch.flatMap((hash) => {
                    const arg = [
                        {
                            methodName: "d.name",
                            params: [hash],
                        },
                        {
                            methodName: "d.size_bytes",
                            params: [hash],
                        },
                        {
                            methodName: "d.directory",
                            params: [hash],
                        },
                        {
                            methodName: "d.is_multi_file",
                            params: [hash],
                        },
                        {
                            methodName: "d.custom1",
                            params: [hash],
                        },
                        {
                            methodName: "f.multicall",
                            params: [hash, "", "f.path=", "f.size_bytes="],
                        },
                        {
                            methodName: "t.multicall",
                            params: [hash, "", "t.url=", "t.group="],
                        },
                    ];
                    numMethods = arg.length;
                    return arg;
                }),
            ];
            try {
                const res = await this.methodCallP("system.multicall", args);
                if (isFault(res)) {
                    logger.error({
                        label: this.label,
                        message: "Fault while getting client torrents",
                    });
                    logger.debug(inspect(res));
                    return [];
                }
                return res;
            }
            catch (e) {
                logger.error({
                    label: this.label,
                    message: `Failed to get client torrents: ${e.message}`,
                });
                logger.debug(e);
                return [];
            }
        }, { batchSize: this.batchSize });
        if (!results.length || results.length !== hashes.length * numMethods) {
            logger.error({
                label: this.label,
                message: `Unexpected number of results: ${results.length} for ${hashes.length} hashes`,
            });
            return { searchees, newSearchees };
        }
        const infoHashes = new Set();
        for (let i = 0; i < hashes.length; i++) {
            const infoHash = hashes[i].toLowerCase();
            infoHashes.add(infoHash);
            const dbTorrent = await db("client_searchee")
                .where("info_hash", infoHash)
                .where("client_host", this.clientHost)
                .first();
            const name = results[i * numMethods][0];
            const directory = results[i * numMethods + 2][0];
            const isMultiFile = Boolean(Number(results[i * numMethods + 3][0]));
            const labels = results[i * numMethods + 4][0];
            const savePath = isMultiFile ? dirname(directory) : directory;
            const tags = labels.length
                ? decodeURIComponent(labels)
                    .split(",")
                    .map((tag) => tag.trim())
                : [];
            const modified = clientSearcheeModified(this.label, dbTorrent, name, savePath, {
                tags,
            });
            const refresh = options?.refresh === undefined
                ? false
                : options.refresh.length === 0
                    ? true
                    : options.refresh.includes(infoHash);
            if (!modified && !refresh) {
                if (!options?.newSearcheesOnly) {
                    searchees.push(createSearcheeFromDB(dbTorrent));
                }
                continue;
            }
            const length = Number(results[i * numMethods + 1][0]);
            const files = results[i * numMethods + 5][0].map((arr) => ({
                name: basename(arr[0]),
                path: isMultiFile ? join(basename(directory), arr[0]) : arr[0],
                length: Number(arr[1]),
            }));
            if (!files.length) {
                logger.verbose({
                    label: this.label,
                    message: `No files found for ${name} [${sanitizeInfoHash(infoHash)}]: skipping`,
                });
                continue;
            }
            const trackers = organizeTrackers(results[i * numMethods + 6][0].map((arr) => ({
                url: arr[0],
                tier: Number(arr[1]),
            })));
            const title = parseTitle(name, files) ?? name;
            const searchee = {
                infoHash,
                name,
                title,
                files,
                length,
                clientHost: this.clientHost,
                savePath,
                tags,
                trackers,
            };
            newSearchees.push(searchee);
            searchees.push(searchee);
        }
        await updateSearcheeClientDB(this.clientHost, newSearchees, infoHashes);
        return { searchees, newSearchees };
    }
    async recheckTorrent(infoHash) {
        // Pause first as it may resume after recheck automatically
        await this.methodCallP("d.pause", [infoHash]);
        await this.methodCallP("d.check_hash", [infoHash]);
    }
    async resumeInjection(meta, decision, options) {
        const infoHash = meta.infoHash;
        let sleepTime = resumeSleepTime;
        const stopTime = getResumeStopTime();
        let stop = false;
        while (Date.now() < stopTime) {
            if (options.checkOnce) {
                if (stop)
                    return;
                stop = true;
            }
            await wait(sleepTime);
            const torrentInfoRes = await this.checkOriginalTorrent(infoHash, {
                onlyCompleted: false,
            });
            if (torrentInfoRes.isErr()) {
                sleepTime = resumeErrSleepTime; // Dropping connections or restart
                continue;
            }
            const torrentInfo = torrentInfoRes.unwrap();
            if (torrentInfo.hashing) {
                continue;
            }
            const torrentLog = `${torrentInfo.name} [${sanitizeInfoHash(infoHash)}]`;
            if (torrentInfo.isActive) {
                logger.warn({
                    label: this.label,
                    message: `Will not resume torrent ${torrentLog}: active`,
                });
                return;
            }
            const maxRemainingBytes = getMaxRemainingBytes(meta, decision, {
                torrentLog,
                label: this.label,
            });
            if (torrentInfo.bytesLeft > maxRemainingBytes) {
                if (!shouldResumeFromNonRelevantFiles(meta, torrentInfo.bytesLeft, decision, { torrentLog, label: this.label })) {
                    logger.warn({
                        label: this.label,
                        message: `autoResumeMaxDownload will not resume ${torrentLog}: remainingSize ${humanReadableSize(torrentInfo.bytesLeft, { binary: true })} > ${humanReadableSize(maxRemainingBytes, { binary: true })} limit`,
                    });
                    return;
                }
            }
            logger.info({
                label: this.label,
                message: `Resuming torrent ${torrentLog}`,
            });
            await this.methodCallP("d.resume", [infoHash]);
        }
        logger.warn({
            label: this.label,
            message: `Will not resume torrent ${infoHash}: timeout`,
        });
    }
    async inject(meta, searchee, decision, options) {
        const existsRes = await this.isTorrentInClient(meta.infoHash);
        if (existsRes.isErr())
            return InjectionResult.FAILURE;
        if (existsRes.unwrap())
            return InjectionResult.ALREADY_EXISTS;
        const result = await this.getDownloadLocation(meta, searchee, options);
        if (result.isErr()) {
            switch (result.unwrapErr()) {
                case "NOT_FOUND":
                    return InjectionResult.FAILURE;
                case "TORRENT_NOT_COMPLETE":
                    return InjectionResult.TORRENT_NOT_COMPLETE;
                case "FAILURE":
                    return InjectionResult.FAILURE;
            }
        }
        const { directoryBase, basePath } = result.unwrap();
        const rawWithLibtorrentResume = {
            ...meta.raw,
            libtorrent_resume: await createLibTorrentResumeTree(meta, basePath),
        };
        const toRecheck = shouldRecheck(meta, decision);
        const loadType = toRecheck ? "load.raw" : "load.raw_start";
        const retries = 5;
        for (let i = 0; i < retries; i++) {
            try {
                await this.methodCallP(loadType, [
                    "",
                    new Metafile(rawWithLibtorrentResume).encode(),
                    `d.directory_base.set="${directoryBase}"`,
                    `d.custom1.set="${TORRENT_TAG}"`,
                    `d.custom.set=addtime,${Math.round(Date.now() / 1000)}`,
                    toRecheck
                        ? `d.check_hash=${meta.infoHash.toUpperCase()}`
                        : null,
                ].filter((e) => e !== null));
                if (toRecheck) {
                    void this.resumeInjection(meta, decision, {
                        checkOnce: false,
                    });
                }
                break;
            }
            catch (e) {
                logger.verbose({
                    label: this.label,
                    message: `Failed to inject torrent ${meta.name} on attempt ${i + 1}/${retries}: ${e.message}`,
                });
                logger.debug(e);
                await wait(1000 * Math.pow(2, i));
            }
        }
        for (let i = 0; i < 5; i++) {
            if ((await this.isTorrentInClient(meta.infoHash)).orElse(false)) {
                return InjectionResult.SUCCESS;
            }
            await wait(100 * Math.pow(2, i));
        }
        return InjectionResult.FAILURE;
    }
}
//# sourceMappingURL=RTorrent.js.map