import cors from "@fastify/cors";
import chalk from "chalk";
import { existsSync } from "fs";
import { parse as qsParse } from "querystring";
import { inspect } from "util";
import { z } from "zod";
import { Decision, InjectionResult, SaveResult, } from "../constants.js";
import { checkJobs, getJobs, JobName } from "../jobs.js";
import { Label, logger } from "../logger.js";
import { checkNewCandidateMatch, searchForLocalTorrentByCriteria, } from "../pipeline.js";
import { getRuntimeConfig } from "../runtimeConfig.js";
import { indexTorrentsAndDataDirs } from "../torrent.js";
import { formatAsList, sanitizeInfoHash } from "../utils.js";
import { authorize } from "../utils/authUtils.js";
const ANNOUNCE_SCHEMA = z
    .object({
    name: z
        .string()
        .transform((name) => name.trim())
        .refine((name) => name.length > 0),
    guid: z.string().url(),
    link: z.string().url(),
    tracker: z
        .string()
        .transform((tracker) => tracker.trim())
        .refine((tracker) => tracker.length > 0),
    cookie: z
        .string()
        .nullish()
        .transform((cookie) => cookie?.trim() || undefined),
})
    .strict()
    .required()
    .refine((data) => data.guid === data.link);
const WEBHOOK_SCHEMA = z
    .object({
    infoHash: z.string().length(40),
    path: z.string().refine((path) => path && existsSync(path)),
    ignoreCrossSeeds: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
    ignoreExcludeRecentSearch: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
    ignoreExcludeOlder: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
    ignoreBlockList: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
    includeSingleEpisodes: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
    includeNonVideos: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
})
    .strict()
    .partial()
    .refine((data) => !data.infoHash !== !data.path);
const JOB_SCHEMA = z
    .object({
    name: z.string(),
    ignoreExcludeRecentSearch: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
    ignoreExcludeOlder: z
        .boolean()
        .or(z.string().transform((v) => v === "true")),
})
    .strict()
    .partial()
    .refine((data) => Object.values(JobName).includes(data.name));
function parseData(data) {
    let parsed;
    try {
        parsed = JSON.parse(data);
    }
    catch {
        parsed = qsParse(data);
    }
    // transformations
    try {
        if ("infoHash" in parsed) {
            parsed.infoHash = parsed.infoHash.toLowerCase();
        }
        if ("size" in parsed && typeof parsed.size === "string") {
            parsed.size = Number(parsed.size);
        }
    }
    catch {
        throw new Error(`Unable to parse request body: "${data}"`);
    }
    return parsed;
}
function determineResponse(result) {
    const injected = result.actionResult === InjectionResult.SUCCESS;
    const added = injected ||
        result.actionResult === InjectionResult.FAILURE ||
        result.actionResult === SaveResult.SAVED;
    const exists = result.decision === Decision.INFO_HASH_ALREADY_EXISTS ||
        result.actionResult === InjectionResult.ALREADY_EXISTS;
    const incomplete = result.actionResult === InjectionResult.TORRENT_NOT_COMPLETE;
    let status;
    let state;
    if (added) {
        status = 200;
        state = injected ? "Injected" : "Saved";
    }
    else if (exists) {
        status = 200;
        state = "Already exists";
    }
    else if (incomplete) {
        status = 202;
        state = "Saved";
    }
    else {
        throw new Error(`Unexpected result: ${result.decision} | ${result.actionResult}`);
    }
    return { status, state };
}
export async function baseApiPlugin(app) {
    // Add query string parsing support
    app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" }, function (req, body, done) {
        try {
            const parsed = parseData(body);
            done(null, parsed);
        }
        catch (error) {
            done(error, undefined);
        }
    });
    // Add JSON body parsing support
    app.addContentTypeParser("application/json", { parseAs: "string" }, function (req, body, done) {
        try {
            const parsed = parseData(body);
            done(null, parsed);
        }
        catch (error) {
            done(error, undefined);
        }
    });
    // Enable CORS for /ping, /webhook, and /announce endpoints
    const CORS_ENABLED_ROUTES = ["/api/ping", "/api/webhook", "/api/announce"];
    await app.register(cors, {
        delegator: (req, callback) => {
            if (CORS_ENABLED_ROUTES.some((route) => req.url.startsWith(route))) {
                callback(null, { origin: "*" });
            }
            else {
                callback(null, { origin: false });
            }
        },
    });
    /**
     * Trigger a search for a torrent
     */
    app.post("/webhook", async (request, reply) => {
        if (!(await authorize(request, reply)))
            return;
        const injectJob = getJobs().find((job) => job.name === JobName.INJECT);
        if (injectJob) {
            injectJob.runAheadOfSchedule = true;
            void checkJobs({ isFirstRun: false, useQueue: true });
        }
        await indexTorrentsAndDataDirs();
        let data;
        try {
            data = request.body;
        }
        catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            logger.error({
                label: Label.WEBHOOK,
                message,
            });
            return reply.code(400).send(message);
        }
        try {
            data = WEBHOOK_SCHEMA.parse(data);
        }
        catch {
            const message = `A valid infoHash or an accessible path must be provided (infoHash is recommended: see https://www.cross-seed.org/docs/reference/api#post-apiwebhook): ${inspect(data)}`;
            logger.error({ label: Label.WEBHOOK, message });
            return reply.code(400).send(message);
        }
        void reply.code(204).send();
        const criteriaStr = data.infoHash
            ? inspect(data).replace(data.infoHash, sanitizeInfoHash(data.infoHash))
            : inspect(data);
        logger.info({
            label: Label.WEBHOOK,
            message: `Received search request: ${criteriaStr}`,
        });
        const configOverride = {
            includeSingleEpisodes: data.includeSingleEpisodes,
            includeNonVideos: data.includeNonVideos,
            excludeRecentSearch: data.ignoreExcludeRecentSearch ? 1 : undefined,
            excludeOlder: data.ignoreExcludeOlder
                ? Number.MAX_SAFE_INTEGER
                : undefined,
            blockList: data.ignoreBlockList ? [] : undefined,
        };
        try {
            let numFound = null;
            if (data) {
                numFound = await searchForLocalTorrentByCriteria(data, {
                    configOverride,
                    ignoreCrossSeeds: data.ignoreCrossSeeds ?? true,
                });
            }
            if (numFound !== null) {
                logger.info({
                    label: Label.WEBHOOK,
                    message: `Found ${numFound} torrents for ${criteriaStr}`,
                });
            }
        }
        catch (e) {
            logger.error({
                label: Label.WEBHOOK,
                message: e.message,
            });
            logger.debug(e);
        }
    });
    /**
     * Reverse lookup for a torrent
     */
    app.post("/announce", async (request, reply) => {
        if (!(await authorize(request, reply)))
            return;
        const { dataDirs, torrentDir, useClientTorrents } = getRuntimeConfig();
        let data;
        try {
            data = request.body;
        }
        catch (e) {
            const message = e.message;
            logger.error({
                label: Label.ANNOUNCE,
                message,
            });
            return reply.code(400).send(message);
        }
        try {
            data = ANNOUNCE_SCHEMA.parse(data);
        }
        catch ({ errors }) {
            const message = `Missing required params (https://www.cross-seed.org/docs/v6-migration#autobrr-update): {${formatAsList(errors.map(({ path }) => path.join(".")), { sort: true, type: "unit" })}} in ${inspect(data)}\n${inspect(errors)}`;
            logger.error({ label: Label.ANNOUNCE, message });
            return reply.code(400).send(message);
        }
        logger.verbose({
            label: Label.ANNOUNCE,
            message: `Received announce from ${data.tracker}: ${data.name}`,
        });
        const candidate = data;
        const candidateLog = `${chalk.bold.white(candidate.name)} from ${candidate.tracker}`;
        try {
            if (!useClientTorrents && !torrentDir && !dataDirs.length) {
                throw new Error(`Announce requires at least one of useClientTorrents, torrentDir, or dataDirs to be set`);
            }
            await indexTorrentsAndDataDirs();
            const result = await checkNewCandidateMatch(candidate, Label.ANNOUNCE);
            if (!result.decision) {
                return await reply.code(204).send();
            }
            const { status, state } = determineResponse(result);
            if (result.actionResult !== InjectionResult.SUCCESS) {
                logger.info({
                    label: Label.ANNOUNCE,
                    message: `${state} ${candidateLog} (status: ${status})`,
                });
            }
            return await reply.code(status).send();
        }
        catch (e) {
            logger.error({
                label: Label.ANNOUNCE,
                message: e.message,
            });
            logger.debug(e);
            return reply.code(500).send(e.message);
        }
    });
    /**
     * Run a job ahead of schedule if eligible
     */
    app.post("/job", async (request, reply) => {
        if (!(await authorize(request, reply)))
            return;
        let data;
        try {
            data = request.body;
        }
        catch (e) {
            const message = e.message;
            logger.error({
                label: Label.SERVER,
                message,
            });
            return reply.code(400).send(message);
        }
        try {
            data = JOB_SCHEMA.parse(data);
        }
        catch {
            const message = `Job name must be one of ${formatAsList(Object.values(JobName), { sort: true, style: "narrow", type: "unit" })} - received: ${inspect(data)}`;
            logger.error({ label: Label.SERVER, message });
            return reply.code(400).send(message);
        }
        logger.info({
            label: Label.SERVER,
            message: `Received job request: ${inspect(data)}`,
        });
        const job = getJobs().find((j) => j.name === data.name);
        if (!job) {
            const message = `${data.name}: unable to run, disabled in config`;
            logger.error({ label: Label.SCHEDULER, message });
            return reply.code(404).send(message);
        }
        if (job.isActive) {
            const message = `${job.name}: already running`;
            logger.error({ label: Label.SCHEDULER, message });
            return reply.code(409).send(message);
        }
        job.runAheadOfSchedule = true;
        job.configOverride = {
            excludeRecentSearch: data.ignoreExcludeRecentSearch ? 1 : undefined,
            excludeOlder: data.ignoreExcludeOlder
                ? Number.MAX_SAFE_INTEGER
                : undefined,
        };
        void checkJobs({ isFirstRun: false, useQueue: true });
        const message = `${job.name}: running ahead of schedule`;
        logger.info({ label: Label.SCHEDULER, message });
        return reply.code(200).send(message);
    });
    /**
     * current: sends "200 OK"
     * future: respond with current state and job status details via API
     * uses: potential usage of this in dashbrr
     */
    app.get("/status", async (request, reply) => {
        if (!(await authorize(request, reply)))
            return;
        return reply.code(200).send("OK");
    });
    /**
     * cross-seed health check
     */
    app.get("/ping", async (request, reply) => {
        return reply.code(200).send("OK");
    });
}
//# sourceMappingURL=baseApi.js.map