/**
 *
 * @param {RequestInfo | URL} url
 * @param {{ body?: any } & RequestInit?} options
 */
async function apiRequest(url, options = null)
{
    console.log(`Fetching :: ${url}`);

    if (options && options.body) {
        options.body = JSON.stringify(options.body);
    }

    return await fetch(url, options ?? {})
        .then(response => {
            if (response.ok) {
                return response;
            }

            throw new Error(`Error fetching ${url}: ${response.status} ${response.statusText}`);
        })
        .then(response => response.json())
        .catch(error => {
            console.error(`Error fetching ${url}: ${error}`);
            return null;
        });
}

function Handle(name, instance) {
    let handleObj = Object.create(Handle.prototype);
    handleObj.name = name;
    handleObj.instance = instance;
    handleObj._baseInstance = null;
    handleObj._apiInstance = null;
    handleObj.profileUrl = null;

    return handleObj;
}

Object.defineProperty(Handle.prototype, "baseInstance", {
    get: function () {
        return this._baseInstance || this.instance;
    }
});

Object.defineProperty(Handle.prototype, "apiInstance", {
    get: function () {
        return this._apiInstance || this.instance;
    }
});

Object.defineProperty(Handle.prototype, "baseHandle", {
    get: function () {
        return this.name + "@" + this.baseInstance;
    }
});

Handle.prototype.toString = function () {
    return this.name + "@" + this.instance;
};

/**
 * @returns {Promise<Handle>} The handle WebFingered, or the original on fail
 */
Handle.prototype.webFinger = async function () {
    if (this._baseInstance) {
        return this;
    }

    let url = `https://${this.instance}/.well-known/webfinger?` + new URLSearchParams({
        resource: `acct:${this}`
    });

    let webFinger = await apiRequest(url);

    if (!webFinger)
        return this;

    let acct = webFinger["subject"];

    if (typeof acct !== "string")
        return this;

    if (acct.startsWith("acct:")) {
        acct = acct.substring("acct:".length);
    }

    let baseHandle = parseHandle(acct);
    baseHandle._baseInstance = baseHandle.instance;
    baseHandle.instance = this.instance;

    const links = webFinger["links"];

    if (!Array.isArray(links)) {
        return baseHandle;
    }

    const selfLink = links.find(link => link["rel"] === "self");
    if (!selfLink) {
        return baseHandle;
    }

    try {
        const url = new URL(selfLink["href"])
        baseHandle._apiInstance = url.hostname;
    } catch (e) {
        console.error(`Error parsing WebFinger self link ${selfLink["href"]}: ${e}`);
    }

    const profileLink = links.find(link => link["rel"] === "http://webfinger.net/rel/profile-page");
    if (profileLink?.["href"]) {
        try {
            baseHandle.profileUrl = new URL(profileLink["href"]);
        } catch (e) {
            console.error(`Error parsing WebFinger profile page link ${profileLink["href"]}: ${e}`);
        }
    }

    return baseHandle;
};


/**
 * @typedef {{
 *    id: string,
 *    avatar: string,
 *    bot: boolean,
 *    name: string,
 *    handle: Handle,
 * }} FediUser
 */

/**
 * @typedef {FediUser & {conStrength: number}} RatedUser
 */

/**
 * @typedef {{
 *    id: string,
 *    replies: number,
 *    renotes: number,
 *    favorites: number,
 *    extra_reacts: boolean,
 *    instance: string,
 *    author?: FediUser,
 * }} Note
 */

class ApiClient {
    /**
     * @param {string} instance
     */
    constructor(instance) {
        this._instance = instance;
    }

    /**
     *
     * @param instance
     * @returns {Promise<ApiClient>}
     */
    static async getClient(instance) {
        if (instanceTypeCache.has(instance)) {
            return instanceTypeCache.get(instance);
        }

        let url = `https://${instance}/.well-known/nodeinfo`;
        let nodeInfo = await apiRequest(url);

        if (!nodeInfo || !Array.isArray(nodeInfo.links)) {
            const client = new MastodonApiClient(instance, true);
            instanceTypeCache.set(instance, client);
            return client;
        }

        const { links } = nodeInfo;

        let apiLink = links.find(link => link.rel === "http://nodeinfo.diaspora.software/ns/schema/2.1");
        if (!apiLink) {
            apiLink = links.find(link => link.rel === "http://nodeinfo.diaspora.software/ns/schema/2.0");
        }

        if (!apiLink) {
            console.error(`No NodeInfo API found for ${instance}}`);
            const client = new MastodonApiClient(instance, true);
            instanceTypeCache.set(instance, client);
            return client;
        }

        let apiResponse = await apiRequest(apiLink.href);

        if (!apiResponse) {
            // Guess from API endpoints
            const misskeyMeta = await apiRequest(`https://${instance}/api/meta`, {
                method: "POST",
                headers: {
                    "Content-Type": "application/json"
                },
                body: {}
            });

            if (misskeyMeta) {
                const client = new MisskeyApiClient(instance);
                instanceTypeCache.set(instance, client);
                return client;
            }

            const client = new MastodonApiClient(instance, true);
            instanceTypeCache.set(instance, client);
            return client;
        }

        let { software } = apiResponse;
        software.name = software.name.toLowerCase();

        if (software.name.includes("fedibird")) {
            const client = new FedibirdApiClient(instance, true);
            instanceTypeCache.set(instance, client);
            return client;
        }

        if (software.name.includes("misskey") ||
            software.name.includes("calckey") ||
            software.name.includes("foundkey") ||
            software.name.includes("magnetar") ||
            software.name.includes("firefish")) {
            const client = new MisskeyApiClient(instance);
            instanceTypeCache.set(instance, client);
            return client;
        }

        let features = apiResponse?.metadata?.features;
        if (Array.isArray(features) && features.includes("pleroma_api")) {
            const has_emoji_reacts = features.includes("pleroma_emoji_reactions");
            const client = new PleromaApiClient(instance, has_emoji_reacts);
            instanceTypeCache.set(instance, client);
            return client;
        }

        const client = new MastodonApiClient(instance, true);
        instanceTypeCache.set(instance, client);
        return client;
    }

    /**
     * @param {Handle} handle
     *
     * @returns {Promise<FediUser>}
     */
    async getUserIdFromHandle(handle){ throw new Error("Not implemented"); }

    /**
     * @param {FediUser} user
     *
     * @returns {Promise<Note[]>}
     */
    async getNotes(user){ throw new Error("Not implemented"); }

    /**
     * @param {Note} note
     *
     * @returns {Promise<FediUser[] | null>}
     */
    async getRenotes(note){ throw new Error("Not implemented"); }

    /**
     * @param {Note} note
     *
     * @returns {Promise<Note[] | null>}
     */
    async getReplies(note){ throw new Error("Not implemented"); }

    /**
     * @param {Note} note
     *
     * @returns {Promise<FediUser[] | null>}
     */
    async getReactions(note){ return []; }

    /**
     * @param {Note} note
     *
     * @returns {Promise<FediUser[] | null>}
     */
    async getFavs(note) { throw new Error("Not implemented"); }

    /**
     * @param {Note} note
     * @param {boolean} extra_reacts Also include emoji reacts
     *
     * @return {Promise<FediUser[] | null>}
     */
    async getConsolidatedReactions(note, extra_reacts = false){
        let favs = await this.getFavs(note);

        if (!extra_reacts)
            return favs;

        /**
         * @type {Map<string, FediUser>}
         */
        let users = new Map();
        if (favs !== null) {
            favs.forEach(u => {
                users.set(u.id, u);
            });
        }

        const reactions = await this.getReactions(note);
        if (reactions !== null) {
            reactions.forEach(u => {
                users.set(u.id, u);
            });
        }


        return Array.from(users.values());
    }

    /**
     * @returns string
     */
    getClientName() { throw new Error("Not implemented"); }
}

class MastodonApiClient extends ApiClient {
    /**
     * @param {string} instance
     * @param {boolean} emoji_reacts
     * @param {MastodonApiClient} flavor
     */
    constructor(instance, emoji_reacts, flavor = MastodonFlavor.MASTODON) {
        super(instance);
        this._emoji_reacts = emoji_reacts;
        this._flavor = flavor;
    }

    async getUserIdFromHandle(handle) {
        const url = `https://${this._instance}/api/v1/accounts/lookup?acct=${handle.baseHandle}`;
        let response = await apiRequest(url, null);

        if (!response) {
            const url = `https://${this._instance}/api/v1/accounts/lookup?acct=${handle}`;
            response = await apiRequest(url, null);
        }

        if (!response) {
            return null;
        }

        return {
            id: response.id,
            avatar: response.avatar,
            bot: response.bot,
            name: response["display_name"],
            handle: handle,
        };
    }

    async getNotes(user) {
        const url = `https://${this._instance}/api/v1/accounts/${user.id}/statuses?exclude_replies=true&exclude_reblogs=true&limit=40`;
        const response = await apiRequest(url, null);

        if (!response) {
            return null;
        }

        if (response?.some(note => note?.["emoji_reactions"]?.length)) {
            this._flavor = MastodonFlavor.FEDIBIRD;
        } else if (response?.some(note => note?.["pleroma"]?.["emoji_reactions"]?.length)) {
            this._flavor = MastodonFlavor.PLEROMA;
        }

        return response.map(note => ({
            id: note.id,
            replies: note["replies_count"] || 0,
            renotes: note["reblogs_count"] || 0,
            favorites: note["favourites_count"],
            extra_reacts: note?.["emoji_reactions"]?.length > 0 || note?.["pleroma"]?.["emoji_reactions"]?.length > 0,
            instance: this._instance,
            author: user
        }));
    }

    async getRenotes(note) {
        const url = `https://${this._instance}/api/v1/statuses/${note.id}/reblogged_by`;
        const response = await apiRequest(url);

        if (!response) {
            return null;
        }

        return response.map(user => ({
            id: user.id,
            avatar: user.avatar,
            bot: user.bot,
            name: user["display_name"],
            handle: parseHandle(user["acct"], note.instance)
        }));
    }

    async getReplies(noteIn) {
        const url = `https://${this._instance}/api/v1/statuses/${noteIn.id}/context`;
        const response = await apiRequest(url);

        if (!response) {
            return null;
        }

        if (response["descendants"]?.some(note => note?.["emoji_reactions"]?.length)) {
            this._flavor = MastodonFlavor.FEDIBIRD;
        } else if (response["descendants"]?.some(note => note?.["pleroma"]?.["emoji_reactions"]?.length)) {
            this._flavor = MastodonFlavor.PLEROMA;
        }

        return response["descendants"].map(note => {

            let handle = parseHandle(note["account"]["acct"], noteIn.instance);

            return {
                id: note.id,
                replies: note["replies_count"] || 0,
                renotes: note["reblogs_count"] || 0,
                favorites: note["favourites_count"],
                extra_reacts: note?.["emoji_reactions"]?.length > 0 || note?.["pleroma"]?.["emoji_reactions"]?.length > 0,
                instance: handle.instance,
                author: {
                    id: note["account"]["id"],
                    bot: note["account"]["bot"],
                    name: note["account"]["display_name"],
                    avatar: note["account"]["avatar"],
                    handle: handle
                }
            };
        });
    }

    async getFavs(note) {
        const url = `https://${this._instance}/api/v1/statuses/${note.id}/favourited_by`;
        const response = await apiRequest(url);

        if (!response) {
            return null;
        }

        return response.map(user => ({
            id: user.id,
            avatar: user.avatar,
            bot: user.bot,
            name: user["display_name"],
            handle: parseHandle(user["acct"], note.instance)
        }));
    }

    async getReactions(note) {
        if (this._flavor === MastodonFlavor.MASTODON) {
            return [];
        }

        return this._flavor.getReactions.call(this, note);
    }

    getClientName() {
        return "mastodon";
    }
}

class PleromaApiClient extends MastodonApiClient {
    /**
     * @param {string} instance
     * @param {boolean} emoji_reacts
     */
    constructor(instance, emoji_reacts) {
        super(instance, emoji_reacts, MastodonFlavor.PLEROMA);
    }

    async getReactions(note) {
        if (!this._emoji_reacts)
            return [];

        const url = `https://${this._instance}/api/v1/pleroma/statuses/${note.id}/reactions`;
        const response = await apiRequest(url) ?? [];

        /**
         * @type {Map<string, FediUser>}
         */
        const users = new Map();

        for (const reaction of response) {
            reaction["accounts"]
                .map(account => ({
                    id: account["id"],
                    avatar: account["avatar"],
                    bot: account["bot"],
                    name: account["display_name"],
                    handle: parseHandle(account["acct"], note.instance)
                }))
                .forEach(u => {
                    if(!users.has(u.id))
                        users.set(u.id, u);
                })
        }

        return Array.from(users.values());
    }

    getClientName() {
        return "pleroma";
    }
}

class FedibirdApiClient extends MastodonApiClient {
    /**
     * @param {string} instance
     * @param {boolean} emoji_reacts
     */
    constructor(instance, emoji_reacts) {
        super(instance, emoji_reacts, MastodonFlavor.FEDIBIRD);
    }

    async getReactions(note) {
        if (!this._emoji_reacts)
            return [];

        /**
         * @type {Map<string, FediUser>}
         */
        let users = new Map();

        const url = `https://${this._instance}/api/v1/statuses/${note.id}/emoji_reactioned_by`;
        const response = await apiRequest(url) ?? [];

        for (const reaction of response) {
            let account = reaction["account"];
            let u = {
                id: account["id"],
                avatar: account["avatar"],
                bot: account["bot"],
                name: account["display_name"],
                handle: parseHandle(account["acct"], note.instance)
            }

            if(!users.has(u.id))
                users.set(u.id, u);
        }

        return Array.from(users.values());
    }

    getClientName() {
        return "fedibird";
    }
}

const MastodonFlavor = {
    MASTODON: MastodonApiClient.prototype,
    PLEROMA: PleromaApiClient.prototype,
    FEDIBIRD: FedibirdApiClient.prototype,
};

class MisskeyApiClient extends ApiClient {
    /**
     * @param {string} instance
     */
    constructor(instance) {
        super(instance);
    }

    async getUserIdFromHandle(handle) {
        const lookupUrl = `https://${this._instance}/api/users/search-by-username-and-host`;
        const lookup = await apiRequest(lookupUrl, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: {
                username: handle.name,
                host: null
            }
        });

        let id = null;

        for (const user of Array.isArray(lookup) ? lookup : []) {
            const isLocal = user?.["host"] === handle.instance ||
                user?.["host"] === handle.baseInstance ||
                this._instance === handle.apiInstance && user?.["host"] === null;

            if (isLocal && user?.["username"] === handle.name && user["id"]) {
                id = user["id"];
                break;
            }
        }

        const url = `https://${this._instance}/api/users/show`;
        const response = await apiRequest(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: {
                id: id ? id : undefined,
                username: handle.name
            }
        });

        if (!response) {
            return null;
        }

        return {
            id: response.id,
            avatar: response["avatarUrl"],
            bot: response["isBot"],
            name: response["name"],
            handle: handle,
        };
    }

    async getNotes(user) {
        const url = `https://${this._instance}/api/users/notes`;
        const response = await apiRequest(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: {
                userId: user.id,
                limit: 70,
                reply: false,
                renote: false,
            }
        });

        if (!response) {
            return null;
        }

        return response.map(note => ({
            id: note.id,
            replies: note["repliesCount"],
            renotes: note["renoteCount"],
            favorites: Object.values(note["reactions"]).reduce((a, b) => a + b, 0),
            extra_reacts: false,
            instance: this._instance,
            author: user
        }));
    }

    async getRenotes(note) {
        const url = `https://${this._instance}/api/notes/renotes`;
        const response = await apiRequest(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: {
                noteId: note.id,
                limit: 50,
            }
        });

        if (!response) {
            return null;
        }

        return response.map(renote => ({
            id: renote["user"]["id"],
            avatar: renote["user"]["avatarUrl"],
            bot: renote["user"]["isBot"] || false,
            name: renote["user"]["name"],
            handle: parseHandle(renote["user"]["username"], renote["user"]["host"] ?? this._instance)
        }));
    }

    async getReplies(note) {
        const url = `https://${this._instance}/api/notes/replies`;
        const response = await apiRequest(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: {
                noteId: note.id,
                limit: 100,
            }
        });

        if (!response) {
            return null;
        }

        return response.map(reply => {
            const handle = parseHandle(reply["user"]["username"], reply["user"]["host"] ?? this._instance);

            return {
                id: reply.id,
                replies: reply["repliesCount"],
                renotes: reply["renoteCount"],
                favorites: Object.values(reply["reactions"]).reduce((a, b) => a + b, 0),
                extra_reacts: false,
                instance: handle.instance,
                author: {
                    id: reply["user"]["id"],
                    avatar: reply["user"]["avatarUrl"],
                    bot: reply["user"]["isBot"] || false,
                    name: reply["user"]["name"],
                    handle: handle
                }
            };
        });
    }

    async getFavs(note) {
        const url = `https://${this._instance}/api/notes/reactions`;
        const response = await apiRequest(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: {
                noteId: note.id,
                limit: 100,
            }
        });

        if (!response) {
            return null;
        }

        return response.map(reaction => ({
            id: reaction["user"]["id"],
            avatar: reaction["user"]["avatarUrl"],
            bot: reaction["user"]["isBot"] || false,
            name: reaction["user"]["name"],
            handle: parseHandle(reaction["user"]["username"], reaction["user"]["host"] ?? this._instance),
        }));
    }

    getClientName() {
        return "misskey";
    }
}

/** @type {Map<string, ApiClient>} */
let instanceTypeCache = new Map();

/**
 * @param {string} fediHandle
 * @param {string} fallbackInstance
 *
 * @returns {Handle}
 */
function parseHandle(fediHandle, fallbackInstance = "") {
    if (fediHandle.charAt(0) === '@')
        fediHandle = fediHandle.substring(1);

    fediHandle = fediHandle.replaceAll(" ", "");
    const [name, instance] = fediHandle.split("@", 2);

    return new Handle(name, instance || fallbackInstance);
}

async function circleMain() {
    let progress = document.getElementById("outInfo");

    const generateBtn = document.getElementById("generateButton");

    generateBtn.style.display = "none";

    let fediHandle = document.getElementById("txt_mastodon_handle");
    const selfUser = await parseHandle(fediHandle.value).webFinger();

    let form = document.getElementById("generateForm");
    let backend = form.backend;
    for (const radio of backend) {
        radio.disabled = true;
    }

    fediHandle.disabled = true;

    let client;
    switch (backend.value) {
        case "mastodon":
            client = new MastodonApiClient(selfUser.apiInstance, true);
            break;
        case "pleroma":
            client = new PleromaApiClient(selfUser.apiInstance, true);
            break;
        case "misskey":
            client = new MisskeyApiClient(selfUser.apiInstance);
            break;
        default:
            progress.innerText = "Detecting instance...";
            client = await ApiClient.getClient(selfUser.apiInstance);

            backend.value = (() => {
                switch (client.getClientName()) {
                    case "fedibird": return "mastodon";
                    default: return client.getClientName();
                }
            })();
            break;
    }

    progress.innerText = "Fetching your user...";

    const user = await client.getUserIdFromHandle(selfUser);

    if (!user) {
        alert("Something went horribly wrong, couldn't fetch your user.");
        fediHandle.disabled = false;
        for (const radio of backend) {
            radio.disabled = false;
        }
        generateBtn.style.display = "inline";
        progress.innerText = "";

        return;
    }

    progress.innerText = "Fetching your latest posts...";

    const notes = await client.getNotes(user);

    if (!notes) {
        alert("Something went horribly wrong, couldn't fetch your notes.");
        return;
    }

    /**
     * @type {Map<string, RatedUser>}
     */
    let connectionList = new Map();
    await processNotes(client, connectionList, notes);

    showConnections(user, connectionList);
}

/**
 * @param {ApiClient} client
 * @param {Map<string, RatedUser>} connectionList
 * @param {Note[]} notes
 */
async function processNotes(client, connectionList, notes) {
    let progress = document.getElementById("outInfo");
    let counter = 0;
    let total = notes.length;

    for (const note of notes) {
        progress.innerText = `Processing :3 (${counter}/${total}) `;
        await evaluateNote(client, connectionList, note);
        counter++;
    }
}

/**
@param {ApiClient} client
 * @param {Map<string, RatedUser>} connectionList
 * @param {Note} note
 */
async function evaluateNote(client, connectionList, note) {
    if (note.favorites > 0 || note.extra_reacts) {
        await client.getConsolidatedReactions(note, note.extra_reacts).then(users => {
            if (!users)
                return;

            users.forEach(user => {
                incConnectionValue(connectionList, user, 1.0);
            });
        }).catch(() => {});
    }

    if (note.renotes > 0) {
        await client.getRenotes(note).then(users => {
            if (!users)
                return;

            users.forEach(user => {
                incConnectionValue(connectionList, user, 1.3);
            });
        }).catch(() => {});
    }

    await client.getReplies(note).then(replies => {
        if (!replies)
            return [];

        replies.forEach(reply => {
            incConnectionValue(connectionList, reply.author, 1.1);
        });

        return replies;
    }).catch(() => {});
}

/**
 * @param {Map<string, RatedUser>} connectionList
 * @param {FediUser} user
 * @param {number} plus
 */
function incConnectionValue(connectionList, user, plus) {
    if (user.bot)
        return;

    if (!connectionList.has(user.id)) {
        connectionList.set(user.id, {
            conStrength: 0,
            ...user
        });
    }

    connectionList.get(user.id).conStrength += plus;
}

/**
 * @param {FediUser} localUser
 * @param {Map<string, RatedUser>} connectionList
 */
function showConnections(localUser, connectionList) {
    if (connectionList.has(localUser.id))
        connectionList.delete(localUser.id);

    // Sort dict into Array items
    const items = [...connectionList.values()].sort((first, second) => second.conStrength - first.conStrength);

    // Also export the Username List
    let usersDivs = [
        document.getElementById("ud1"),
        document.getElementById("ud2"),
        document.getElementById("ud3")
    ];

    usersDivs.forEach((div) => div.innerHTML = "")

    const [inner, middle, outer] = usersDivs;
    inner.innerHTML = "<div><h3>Inner Circle</h3></div>";
    middle.innerHTML = "<div><h3>Middle Circle</h3></div>";
    outer.innerHTML = "<div><h3>Outer Circle</h3></div>";

    for (let i= 0; i < items.length; i++) {
        const newUser = document.createElement("a");
        newUser.className = "userItem";
        newUser.innerText = items[i].handle.name;
        newUser.title = items[i].name;
        // I'm so sorry
        newUser.href = "javascript:void(0)";
        const handle = items[i].handle;
        newUser.onclick = async () => {
            const fingeredHandle = await handle.webFinger();
            if (fingeredHandle.profileUrl)
                window.open(fingeredHandle.profileUrl, "_blank");
            else
                alert("Could not fetch the profile URL for " + fingeredHandle.baseHandle);
        };

        const newUserHost = document.createElement("span");
        newUserHost.className = "userHost";
        newUserHost.innerText = "@" + items[i].handle.instance;
        newUser.appendChild(newUserHost);

        const newUserImg = document.createElement("img");
        newUserImg.src = items[i].avatar;
        newUserImg.alt = "";
        newUserImg.className = "userImg";
        newUserImg.onload = () => {
            newUserImg.title = newUserImg.alt = stripName(items[i].name || items[i].handle.name) + "'s avatar";
        };
        newUser.prepend(newUserImg);

        let udNum = 0;
        if (i > numb[0]) udNum = 1;
        if (i > numb[0] + numb[1]) udNum = 2;
        usersDivs[udNum].appendChild(newUser);
    }

    usersDivs.forEach((div) => {
        const items = div.querySelectorAll(".userItem");

        for (let i = 0; i < items.length - 1; i++) {
            const item = items[i];
            item.appendChild(document.createTextNode(", "));
        }
    });

    const outDiv = document.getElementById("outDiv");
    outDiv.style.display = "block";
    document.getElementById("outSelfUser").innerText = stripName(localUser.name || localUser.handle.name);

    render(items, localUser);
}

function stripName(name) {
    return name.replaceAll(/:[a-zA-Z0-9_]+:/g, "").trim();
}