import { InternalService } from "@autoplex/microservice";
import assert from "assert";
import MagnetUri from "magnet-uri";
import WebTorrent from "webtorrent-hybrid";
import parseTorrent from "parse-torrent";
import { lstat } from "fs/promises";
import { extname, join, sep } from "path";
import rimraf from "rimraf";
import Torrent from "../database/entities/Torrent";
import { ISerializedTorrent, TorrentState } from "../common";

interface IAddOptions {
    downloadPath?: string;
    extensions?: string | string[];
}

/**
 * Available events in the torrent client
 */
interface TorrentClientEvents {
    "torrent_finished": (torrent: WebTorrent.Torrent) => void;
}

/**
 * Declare the event types in the torrent client
 */
export declare interface TorrentClient {
    emit<U extends keyof TorrentClientEvents>(
        event: U, ...args: Parameters<TorrentClientEvents[U]>
    ): boolean;
    on<U extends keyof TorrentClientEvents>(
        event: U, listener: TorrentClientEvents[U]
    ): this;
}

export class TorrentClient extends InternalService
{
    /**
     * The current WebTorrent instance (available after boot)
     */
    private __webtorrent!: WebTorrent.Instance;

    // ---------------------------------------------------------------------------------------------

    /**
     * The service name
     */
    public readonly NAME = "Torrent Client";

    /**
     * Boot the service
     */
    public async boot() {
        this.__webtorrent = new WebTorrent();

        this.__webtorrent.on("error", error => this.onError(error));
        this.__webtorrent.on("torrent", torrent => {
            torrent.on("done",            () => this.onTorrentFinish(torrent));
            // torrent.on("download", (...args) => this.onTorrentDownload(torrent, ...args));
            // torrent.on("upload",   (...args) => this.onTorrentUpload(torrent, ...args));
            torrent.on("error",    (...args) => this.onTorrentError(torrent, ...args));
            torrent.on("noPeers",  (...args) => this.onTorrentNoPeers(torrent, ...args));
            torrent.on("wire",     (...args) => this.onTorrentWire(torrent, ...args));
        });
    }

    /**
     * @TODO really this is kinda bad putting this here...
     * Start the torrent client
     */
    public start() {
        this.log("Loading torrents...");
        this.loadTorrents();
    }

    // Event Handling ------------------------------------------------------------------------------

    /**
     * Invoked when a WebTorrent client error occurs
     */
    protected onError(error: string | Error) {
        console.error("A Webtorrent client error occurred:", error);
    }

    /**
     * Invoked when a torrent error occurs
     */
    protected onTorrentError(torrent: WebTorrent.Torrent, error: string | Error) {
        console.error("Torrent error occurred:", torrent.name, error);
        Torrent.delete({ infoHash: torrent.infoHash });
    }

    /**
     * Invoked when a torrent has finished
     */
    protected onTorrentFinish(torrent: WebTorrent.Torrent) {
        this.log("Torrent finished:", torrent.name);
        this.emit("torrent_finished", torrent);
    }

    /**
     * @NOTE: Ignoring these two events for performance purposes
     */
    // protected onTorrentDownload(torrent: WebTorrent.Torrent, bytes: number) {}
    // protected onTorrentUpload(torrent: WebTorrent.Torrent, bytes: number) {}

    protected onTorrentNoPeers(torrent: WebTorrent.Torrent, announceTypes: "tracker" | "dht") {

    }

    protected onTorrentWire(torrent: WebTorrent.Torrent, wire: any, address: string | undefined) {

    }

    // Torrent Storage -----------------------------------------------------------------------------

    /**
     * Load the torrents from the database
     */
    protected async loadTorrents() {
        let torrents = await Torrent.find();
        for (let torrent of torrents) {
            try {
                let torrentInfo = parseTorrent(torrent.torrentFile);
                this.addTorrent(torrentInfo, torrent.downloadPath, torrent.selectedFiles());
            } catch(e) {
                torrent.remove();
                continue;
            }
        }
    }

    /**
     * Delete a torrent from storage
     */
    protected async deleteTorrent(torrent: WebTorrent.Torrent) {
        return await Torrent.delete({ infoHash: torrent.infoHash });
    }

    /**
     * Store a torrent in the database
     */
    protected async storeTorrent(torrent: WebTorrent.Torrent) {
        return await Torrent.fromWebTorrent(torrent).save();
    }

    /**
     * Delete the download files from a torrent
     */
    protected async removeTorrentFiles(torrent: WebTorrent.Torrent) {
        return new Promise<void>((resolve, reject) => {
            let toRemove = new Set<string>();
            torrent.files.forEach(file => {
                toRemove.add(file.path.split(sep)[0]);
            });
            toRemove.forEach(path => {
                this.log("Removing:", join(torrent.path, path));
                rimraf(join(torrent.path, path), (err) => {
                    if (err) {
                        return reject(err);
                    }
                    resolve();
                });
            });
        });
    }

    // Torrent Handling ----------------------------------------------------------------------------

    /**
     * Add a torrent to the client
     */
    protected addTorrent(torrent: MagnetUri.Instance, downloadPath: string, selectOnly?: number[]) {
        // Select only: Select no files by default due to broken selection mechanism in Webtorrent
        torrent.so = selectOnly ?? [];
        this.log("Torrent added:", torrent.infoHash);
        return this.__webtorrent.add(torrent, { path: downloadPath });
    }

    /**
     * Remove a torrent from the client
     */
    protected async removeTorrent(torrent: WebTorrent.Torrent) {
        await this.deleteTorrent(torrent);
        // this.log("Torrent removed:", torrent.infoHash);
        this.__webtorrent.remove(torrent);
    }

    /**
     * Select the initial list of files to download on a newly added torrent
     */
    protected filterSelectFiles(torrent: WebTorrent.Torrent, extensions?: string | string[]) {
        if (extensions === undefined) {
            torrent.files.forEach(file => file.select());
            return;
        }
        let exts = new Set(typeof extensions === "string" ? extensions.split(',') : extensions);
        torrent.files.forEach(file => {
            if (exts.has(extname(file.path).slice(1))) {
                file.select();
            } else {
                file.deselect();
            }
        });
    }

    // Torrent Client Interface --------------------------------------------------------------------

    /**
     * Add a torrent to the client
     */
    public async add(info: string | Buffer, options: IAddOptions = {}) {
        // Parse the torrent
        let torrentInfo = parseTorrent(info);
        assert(typeof torrentInfo.infoHash === "string" && torrentInfo.infoHash.length > 0, "Invalid magnet link provided");

        // If the torrent already exists, skip it
        assert(this.__webtorrent.get(torrentInfo.infoHash) === null, "Torrent has already been added");

        // Ensure that the download path exists
        let downloadPath = options.downloadPath ?? "/mnt/storage/Downloads";
        assert((await lstat(downloadPath)).isDirectory(), "The provided download path is invalid");

        // Add the torrent to the client
        let torrent = this.addTorrent(torrentInfo, downloadPath);

        // When the metadata has beened fetched, select the files to download and store the torrent
        torrent.once("metadata", () => {
            this.filterSelectFiles(torrent, options.extensions);
            this.storeTorrent(torrent);
            this.log(torrent.path);
        });

        return torrent;
    }

    /**
     * Remove a torrent from the client
     */
    public async remove(info: string | Buffer, withData: boolean = false) {
        // Parse the torrent
        let torrentInfo = parseTorrent(info);
        assert(typeof torrentInfo.infoHash === "string");

        // Get the torrent and ensure it exists it exists
        let torrent = this.__webtorrent.get(torrentInfo.infoHash);
        assert(torrent !== null, "Torrent has not been added");

        // Pause the torrent to prevent further download
        torrent.pause();

        // Delete data if necessary
        if (withData) {
            this.removeTorrentFiles(torrent);
        }

        // Remove the torrent
        await this.removeTorrent(torrent);
    }

    public has(infoHash: string) {
        return this.__webtorrent.get(infoHash) != null;
    }

    public get(infoHash: string) {
        return this.__webtorrent.get(infoHash);
    }

    public torrentState(torrent: WebTorrent.Torrent) {
        let state = 0;
        state |= <number>(torrent.ready  && TorrentState.Ready);
        state |= <number>(torrent.paused && TorrentState.Paused);
        state |= <number>(torrent.done   && TorrentState.Done);
        return <TorrentState>state;
    }

    public serializeTorrent(torrent: WebTorrent.Torrent) {
        return <ISerializedTorrent>{
            name: torrent.name,
            infoHash: torrent.infoHash,
            downloaded: torrent.downloaded,
            uploaded: torrent.uploaded,
            ratio: torrent.ratio,
            size: torrent.length,
            downloadSpeed: torrent.downloadSpeed,
            uploadSpeed: torrent.uploadSpeed,
            numPeers: torrent.numPeers,
            progress: torrent.progress,
            path: torrent.path,
            state: this.torrentState(torrent),
            files: torrent.files.map(file => Object({
                path: file.path,
                size: file.length,
                downloaded: file.downloaded,
                progress: file.progress,
                selected: file.isSelected
            }))
        };
    }

    get torrents() {
        return this.__webtorrent.torrents;
    }
}

export default TorrentClient;
