import type { NDK } from "../../../ndk/index.js";
import { NDKRelay } from "../../../relay/index.js";
import type { NDKFilter } from "../../../subscription/index.js";
import { NDKUser } from "../../../user/index.js";
import type { NDKEventId, NDKTag, NostrEvent } from "../../index.js";
import { NDKEvent } from "../../index.js";
import { NDKKind } from "../index.js";

export type NDKListItem = NDKRelay | NDKUser | NDKEvent;

/**
 * Represents any NIP-51 list kind.
 *
 * This class provides some helper methods to manage the list, particularly
 * a CRUD interface to list items.
 *
 * List items can be encrypted or not. Encrypted items are JSON-encoded and
 * self-signed by the user's key.
 *
 * @example Adding an event to the list.
 * const event1 = new NDKEvent(...);
 * const list = new NDKList();
 * list.addItem(event1);
 *
 * @example Adding an encrypted `p` tag to the list with a "person" mark.
 * const secretFollow = new NDKUser(...);
 * list.addItem(secretFollow, 'person', true);
 *
 * @emits change
 * @group Kind Wrapper
 */
export class NDKList extends NDKEvent {
    public _encryptedTags: NDKTag[] | undefined;
    static kind = NDKKind.CategorizedBookmarkList;
    static kinds: NDKKind[] = [
        NDKKind.CategorizedBookmarkList,
        NDKKind.CommunityList,
        NDKKind.DirectMessageReceiveRelayList,
        NDKKind.EmojiList,
        NDKKind.InterestList,
        NDKKind.PinList,
        NDKKind.RelayList,
        NDKKind.SearchRelayList,
        NDKKind.BlockRelayList,
        NDKKind.BookmarkList,
        NDKKind.RelayFeedList,
    ];

    /**
     * Stores the number of bytes the content was before decryption
     * to expire the cache when the content changes.
     */
    private encryptedTagsLength: number | undefined;

    constructor(ndk?: NDK, rawEvent?: NostrEvent | NDKEvent) {
        super(ndk, rawEvent);
        this.kind ??= NDKKind.CategorizedBookmarkList;
    }

    /**
     * Wrap a NDKEvent into a NDKList
     */
    static from(ndkEvent: NDKEvent): NDKList {
        return new NDKList(ndkEvent.ndk, ndkEvent);
    }

    /**
     * Returns the title of the list. Falls back on fetching the name tag value.
     */
    get title(): string | undefined {
        const titleTag = this.tagValue("title") || this.tagValue("name");
        if (titleTag) return titleTag;

        if (this.kind === NDKKind.Contacts) {
            return "Contacts";
        }
        if (this.kind === NDKKind.MuteList) {
            return "Mute";
        }
        if (this.kind === NDKKind.PinList) {
            return "Pinned Notes";
        }
        if (this.kind === NDKKind.RelayList) {
            return "Relay Metadata";
        }
        if (this.kind === NDKKind.BookmarkList) {
            return "Bookmarks";
        }
        if (this.kind === NDKKind.CommunityList) {
            return "Communities";
        }
        if (this.kind === NDKKind.PublicChatList) {
            return "Public Chats";
        }
        if (this.kind === NDKKind.BlockRelayList) {
            return "Blocked Relays";
        }
        if (this.kind === NDKKind.SearchRelayList) {
            return "Search Relays";
        }
        if (this.kind === NDKKind.DirectMessageReceiveRelayList) {
            return "Direct Message Receive Relays";
        }
        if (this.kind === NDKKind.RelayFeedList) {
            return "Relay Feeds";
        }
        if (this.kind === NDKKind.InterestList) {
            return "Interests";
        }
        if (this.kind === NDKKind.EmojiList) {
            return "Emojis";
        }
        return this.tagValue("d");
    }

    /**
     * Sets the title of the list.
     */
    set title(title: string | undefined) {
        this.removeTag(["title", "name"]);

        if (title) this.tags.push(["title", title]);
    }

    /**
     * Returns the name of the list.
     * @deprecated Please use "title" instead.
     */
    get name(): string | undefined {
        return this.title;
    }

    /**
     * Sets the name of the list.
     * @deprecated Please use "title" instead. This method will use the `title` tag instead.
     */
    set name(name: string | undefined) {
        this.title = name;
    }

    /**
     * Returns the description of the list.
     */
    get description(): string | undefined {
        return this.tagValue("description");
    }

    /**
     * Sets the description of the list.
     */
    set description(name: string | undefined) {
        this.removeTag("description");
        if (name) this.tags.push(["description", name]);
    }

    /**
     * Returns the image of the list.
     */
    get image(): string | undefined {
        return this.tagValue("image");
    }

    /**
     * Sets the image of the list.
     */
    set image(name: string | undefined) {
        this.removeTag("image");
        if (name) this.tags.push(["image", name]);
    }

    private isEncryptedTagsCacheValid(): boolean {
        return !!(this._encryptedTags && this.encryptedTagsLength === this.content.length);
    }

    /**
     * Returns the decrypted content of the list.
     */
    async encryptedTags(useCache = true): Promise<NDKTag[]> {
        if (useCache && this.isEncryptedTagsCacheValid()) return this._encryptedTags!;

        if (!this.ndk) throw new Error("NDK instance not set");
        if (!this.ndk.signer) throw new Error("NDK signer not set");

        const user = await this.ndk.signer.user();

        try {
            if (this.content.length > 0) {
                try {
                    const decryptedContent = await this.ndk.signer.decrypt(user, this.content);
                    const a = JSON.parse(decryptedContent);
                    if (a?.[0]) {
                        this.encryptedTagsLength = this.content.length;
                        return (this._encryptedTags = a);
                    }
                    this.encryptedTagsLength = this.content.length;
                    return (this._encryptedTags = []);
                } catch (_e) {}
            }
        } catch (_e) {
            // console.trace(e);
            // throw e;
        }

        return [];
    }

    /**
     * This method can be overriden to validate that a tag is valid for this list.
     *
     * (i.e. the NDKPersonList can validate that items are NDKUser instances)
     */
    public validateTag(_tagValue: string): boolean | string {
        return true;
    }

    getItems(type: string): NDKTag[] {
        return this.tags.filter((tag) => tag[0] === type);
    }

    /**
     * Returns the unecrypted items in this list.
     */
    get items(): NDKTag[] {
        return this.tags.filter((t) => {
            return ![
                "d",
                "L",
                "l",
                "title",
                "name",
                "description",
                "published_at",
                "summary",
                "image",
                "thumb",
                "alt",
                "expiration",
                "subject",
                "client",
            ].includes(t[0]);
        });
    }

    /**
     * Adds a new item to the list.
     * @param relay Relay to add
     * @param mark Optional mark to add to the item
     * @param encrypted Whether to encrypt the item
     * @param position Where to add the item in the list (top or bottom)
     */
    async addItem(
        item: NDKListItem | NDKTag,
        mark: string | undefined = undefined,
        encrypted = false,
        position: "top" | "bottom" = "bottom",
    ): Promise<void> {
        if (!this.ndk) throw new Error("NDK instance not set");
        if (!this.ndk.signer) throw new Error("NDK signer not set");

        let tags: NDKTag[];

        if (item instanceof NDKEvent) {
            tags = [item.tagReference(mark)];
        } else if (item instanceof NDKUser) {
            tags = item.referenceTags();
        } else if (item instanceof NDKRelay) {
            tags = item.referenceTags();
        } else if (Array.isArray(item)) {
            // NDKTag
            tags = [item];
        } else {
            throw new Error("Invalid object type");
        }

        if (mark) tags[0].push(mark);

        if (encrypted) {
            const user = await this.ndk.signer.user();
            const currentList = await this.encryptedTags();

            if (position === "top") currentList.unshift(...tags);
            else currentList.push(...tags);

            this._encryptedTags = currentList;
            this.encryptedTagsLength = this.content.length;
            this.content = JSON.stringify(currentList);
            await this.encrypt(user);
        } else {
            if (position === "top") this.tags.unshift(...tags);
            else this.tags.push(...tags);
        }

        this.created_at = Math.floor(Date.now() / 1000);

        this.emit("change");
    }

    /**
     * Removes an item from the list from both the encrypted and unencrypted lists.
     * @param value value of item to remove from the list
     * @param publish whether to publish the change
     * @returns
     */
    async removeItemByValue(value: string, publish = true): Promise<Set<NDKRelay> | undefined> {
        if (!this.ndk) throw new Error("NDK instance not set");
        if (!this.ndk.signer) throw new Error("NDK signer not set");

        // check in unecrypted tags
        const index = this.tags.findIndex((tag) => tag[1] === value);
        if (index >= 0) {
            this.tags.splice(index, 1);
        }

        // check in encrypted tags
        const user = await this.ndk.signer.user();
        const encryptedTags = await this.encryptedTags();

        const encryptedIndex = encryptedTags.findIndex((tag) => tag[1] === value);
        if (encryptedIndex >= 0) {
            encryptedTags.splice(encryptedIndex, 1);
            this._encryptedTags = encryptedTags;
            this.encryptedTagsLength = this.content.length;
            this.content = JSON.stringify(encryptedTags);
            await this.encrypt(user);
        }

        if (publish) {
            return this.publishReplaceable();
        }
        this.created_at = Math.floor(Date.now() / 1000);

        this.emit("change");
    }

    /**
     * Removes an item from the list.
     *
     * @param index The index of the item to remove.
     * @param encrypted Whether to remove from the encrypted list or not.
     */
    async removeItem(index: number, encrypted: boolean): Promise<NDKList> {
        if (!this.ndk) throw new Error("NDK instance not set");
        if (!this.ndk.signer) throw new Error("NDK signer not set");

        if (encrypted) {
            const user = await this.ndk.signer.user();
            const currentList = await this.encryptedTags();

            currentList.splice(index, 1);
            this._encryptedTags = currentList;
            this.encryptedTagsLength = this.content.length;
            this.content = JSON.stringify(currentList);
            await this.encrypt(user);
        } else {
            this.tags.splice(index, 1);
        }

        this.created_at = Math.floor(Date.now() / 1000);

        this.emit("change");

        return this;
    }

    public has(item: string) {
        return this.items.some((tag) => tag[1] === item);
    }

    /**
     * Creates a filter that will result in fetching
     * the items of this list
     * @example
     * const list = new NDKList(...);
     * const filters = list.filterForItems();
     * const events = await ndk.fetchEvents(filters);
     */
    filterForItems(): NDKFilter[] {
        const ids = new Set<NDKEventId>();
        const nip33Queries = new Map<string, string[]>();
        const filters: NDKFilter[] = [];

        for (const tag of this.items) {
            if (tag[0] === "e" && tag[1]) {
                ids.add(tag[1]);
            } else if (tag[0] === "a" && tag[1]) {
                const [kind, pubkey, dTag] = tag[1].split(":");
                if (!kind || !pubkey) continue;

                const key = `${kind}:${pubkey}`;
                const item = nip33Queries.get(key) || [];
                item.push(dTag || "");
                nip33Queries.set(key, item);
            }
        }

        if (ids.size > 0) {
            filters.push({ ids: Array.from(ids) });
        }

        if (nip33Queries.size > 0) {
            for (const [key, values] of nip33Queries.entries()) {
                const [kind, pubkey] = key.split(":");
                filters.push({
                    kinds: [Number.parseInt(kind)],
                    authors: [pubkey],
                    "#d": values,
                });
            }
        }

        return filters;
    }
}

export default NDKList;
