/*
 * SPDX-FileCopyrightText: syuilo, tamaina and other misskey contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 */

import { Note, UserLite, DriveFile } from "misskey-js/built/entities";
import { Ref, ref, ComputedRef, computed, watch, unref } from "vue";
import { api } from "./api";
import { useStream } from '@/stream';
import { Stream } from "misskey-js";
import { $i } from "@/account";
import { defaultStore, noteViewInterruptors } from '@/store';
import { deepClone } from "./clone";
import { shouldCollapsed } from "./collapsed";
import { extractUrlFromMfm } from "./extract-url-from-mfm";
import * as mfm from 'mfm-js';

export class EntitiyManager<T extends { id: string }> {
    private entities: Map<T['id'], Ref<T>>;

    constructor(
        public key: string,
    ) {
        this.entities = new Map();
    }

    public set(item: T): Ref<T> {
        const cached = this.entities.get(item.id);
        if (cached) {
            cached.value = item;
        } else {
            this.entities.set(item.id, ref(item) as Ref<T>);
        }
        return this.get(item.id)!;
    }

    public get(id: string): Ref<T> | undefined {
        return this.entities.get(id);
    }
}

export const userLiteManager = new EntitiyManager<UserLite>('userLite');
export const driveFileManager = new EntitiyManager<DriveFile>('driveFile');

type OmittedNote = Omit<Note, 'user' | 'renote' | 'reply'>;
type CachedNoteSource = Ref<OmittedNote | null>;
type CachedNote = ComputedRef<Note | null>;
type InterruptedCachedNote = Ref<Note | null>;

export function isRenote(note: Note | OmittedNote | null): boolean {
    return note != null &&
        note.renoteId != null &&
        note.text == null &&
        note.fileIds?.length === 0 &&
        note.poll == null;
}

/**
 * ノートのキャッシュを管理する
 * 基本的な使い方:
 *   1. setでノートのデータをセットする
 *   2. useNoteでデータを取得+監視
 */
export class NoteManager {
    /**
     * ノートのソースとなるRef
     * user, renote, replyを持たない
     * nullは削除済みであることを表す
     * 
     * 削除する機構はないので溜まる一方だが、メモリ使用量はそこまで気にしなくて良さそう
     */
    private notesSource: Map<Note['id'], CachedNoteSource>;

    /**
     * ソースからuser, renote, replyを取得したComputedRefのキャッシュを保持しておく
     * nullは削除済みであることを表す
     * キャプチャが0になったら削除される
     */
    private notesComputed: Map<Note['id'], CachedNote>;

    private updatedAt: Map<Note['id'], number>;
    private captureing: Map<Note['id'], number>;
    private connection: Stream | null;

    constructor() {
        this.notesSource = new Map();
        this.notesComputed = new Map();
        this.updatedAt = new Map();
        this.captureing = new Map();
        this.connection = $i ? useStream() : null;
        this.connection?.on('noteUpdated', noteData => this.onStreamNoteUpdated(noteData));
        this.connection?.on('_connected_', () => {
            // 再接続時に再キャプチャ
            for (const [id, captureingNumber] of Array.from(this.captureing)) {
                if (captureingNumber === 0) {
                    this.captureing.delete(id);
                    continue;
                }

                this.connection?.send('s', { id });
            }
        });
    }

    public set(_note: Note): void {
        const note: Note = { ..._note };

        userLiteManager.set(note.user);
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        //@ts-ignore
        delete note.user;

        if (note.fileIds.length > 0) {
            for (const file of note.files) {
                driveFileManager.set(file);
            }
        }
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        //@ts-ignore
        delete note.files;

        if (note.renote) this.set(note.renote);
        delete note.renote;

        if (note.reply) this.set(note.reply);
        delete note.reply;

        const cached = this.notesSource.get(note.id);
        if (cached) {
            cached.value = note;
        } else {
            this.notesSource.set(note.id, ref(note));
        }
        this.updatedAt.set(note.id, Date.now());
    }

    public get(id: string): CachedNote {
        if (!this.notesComputed.has(id)) {
            const note = this.notesSource.get(id) ?? this.notesSource.set(id, ref(null)).get(id)!;

            this.notesComputed.set(id, computed<Note | null>(() => {
                if (!note.value) return null;

                const user = userLiteManager.get(note.value.userId)!;

                const renote = note.value.renoteId ? this.get(note.value.renoteId) : undefined;
                // renoteが削除されている場合はCASCADE削除されるためnullを返す
                if (renote && !renote.value) return null;

                const reply = note.value.replyId ? this.get(note.value.replyId) : undefined;
                if (reply && !reply.value) return null;

                const files = note.value.fileIds.map(id => driveFileManager.get(id)?.value);

                return {
                    ...note.value,
                    user: user.value,
                    renote: renote?.value ?? undefined,
                    reply: reply?.value ?? undefined,
                    files: files.filter(file => file) as DriveFile[],
                };
            }));
        }
        return this.notesComputed.get(id)!;
    }

    /**
     * Interruptorを適用する
     * 管理が面倒なのでキャッシュはしない
     */
    public getInterrupted(id: string): {
        interruptedNote: InterruptedCachedNote,
        interruptorUnwatch: () => void,
        executeInterruptor: () => Promise<void>,
    } {
        const note = this.get(id);
        const interruptedNote = ref<Note | null>(note.value);

        async function executeInterruptor() {
            if (note.value == null) {
                interruptedNote.value = null;
                return;
            }

            if (noteViewInterruptors.length === 0) {
                interruptedNote.value = note.value;
                return;
            }

            let result = deepClone(note.value);
            for (const interruptor of noteViewInterruptors) {
                result = await interruptor.handler(result) as Note;
            }
            interruptedNote.value = result;
        }
        const interruptorUnwatch = watch(note, executeInterruptor);

        return {
            interruptedNote,
            interruptorUnwatch,
            executeInterruptor,
        };
    }

    /**
     * ノートの表示に必要なデータをお膳立てする
     */
    public getNoteViewBase(id: string) {
        const { interruptedNote: note, interruptorUnwatch, executeInterruptor } = this.getInterrupted(id);
        const noteIsRenote = computed(() => isRenote(note.value));
        const isMyRenote = computed(() => noteIsRenote.value && $i && ($i.id === note.value?.userId));
        const appearNote = computed(() => (noteIsRenote.value ? note.value?.renote : note.value) ?? null);

        return {
            note, interruptorUnwatch, executeInterruptor,
            isRenote: noteIsRenote, isMyRenote, appearNote,
            urls: computed(() => appearNote.value?.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)) : null),
            isLong: computed(() => appearNote.value ? shouldCollapsed(appearNote.value) : false),
            canRenote: computed(() => (!!appearNote.value && !!$i) && (['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id)),
            showTicker: computed(() => !!appearNote.value && ((defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance))),
        };
    }

    public async fetch(id: string, force = false): Promise<CachedNote> {
        if (!force) {
            const cachedNote = this.get(id);
            if (cachedNote.value === null) {
                // 削除されている場合はnullを返す
                return cachedNote;
            }
            // Renoteの場合はRenote元の更新日時も考慮する
            const updatedAt = isRenote(cachedNote.value) ?
                this.updatedAt.get(id) :
                Math.max(this.updatedAt.get(id) ?? 0, this.updatedAt.get(cachedNote.value!.renoteId!) ?? 0);
            // 2分以上経過していない場合はキャッシュを返す
            if (updatedAt && Date.now() - updatedAt < 1000 * 120) {
                if (cachedNote) {
                    return cachedNote;
                }
            }
        }
        return api('notes/show', { noteId: id })
            .then(fetchedNote => {
                this.set(fetchedNote);
                return this.get(id)!;
            })
            .catch(() => {
                // エラーが発生した場合は何もしない
                return this.get(id)!;
            });
    }

	private onStreamNoteUpdated(noteData: any): void {
		const { type, id, body } = noteData;

        const note = this.notesSource.get(id);

		if (!note || !note.value) {
            this.connection?.send('un', { id });
            this.captureing.delete(id);
            this.notesComputed.delete(id);
            this.updatedAt.delete(id);
            return;
        }

		switch (type) {
			case 'reacted': {
				const reaction = body.reaction;

				if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
					note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
				}

				// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
				const currentCount = (note.value.reactions || {})[reaction] || 0;

				note.value.reactions[reaction] = currentCount + 1;

				if ($i && (body.userId === $i.id)) {
					note.value.myReaction = reaction;
				}
				break;
			}

			case 'unreacted': {
				const reaction = body.reaction;

				// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
				const currentCount = (note.value.reactions || {})[reaction] || 0;

				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
				if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];

				if ($i && (body.userId === $i.id)) {
					note.value.myReaction = undefined;
				}
				break;
			}

			case 'pollVoted': {
				const choice = body.choice;

				const choices = [...note.value.poll!.choices];
				choices[choice] = {
					...choices[choice],
					votes: choices[choice].votes + 1,
					...($i && (body.userId === $i.id) ? {
						isVoted: true,
					} : {}),
				};

				note.value.poll!.choices = choices;
				break;
			}

			case 'deleted': {
				note.value = null;
                this.connection?.send('un', { id });
                this.captureing.delete(id);
                this.notesComputed.delete(id);
                this.updatedAt.delete(id);
				break;
			}
		}

        this.updatedAt.set(id, Date.now());
	}

    private capture(id: string, markRead = true): void {
        if (!this.notesSource.has(id)) return;

        const captureingNumber = this.captureing.get(id);
        if (typeof captureingNumber === 'number' && captureingNumber > 0) {
            this.captureing.set(id, captureingNumber + 1);
            return;
        }

        if (this.connection) {
            // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
            this.connection.send(markRead ? 'sr' : 's', { id });
        }

        this.captureing.set(id, 1);
	}

    private decapture(id: string, noDeletion = false): void {
        if (!this.notesSource.has(id)) return;

        const captureingNumber = this.captureing.get(id);
        if (typeof captureingNumber === 'number' && captureingNumber > 1) {
            this.captureing.set(id, captureingNumber - 1);
            return;
        }

        if (this.connection) {
            this.connection.send('un', { id });
        }

        this.captureing.delete(id);

        // キャプチャが終わったらcomputedキャッシュも消してしまう
        if (!noDeletion) this.notesComputed.delete(id);
	}

    /**
     * ノートを取得・監視
     * キャプチャが要らなくなったら必ずunuseすること
     * @param id note id
     * @returns { note, unuse } note: CachedNote | Promise<CachedNote>, unuse: () => void
     */
    public useNote(id: string, shoudFetch: true): { note: Promise<CachedNote>, unuse: () => void };
    public useNote(id: string, shoudFetch = false) {
        const note = (!this.notesSource.has(id) || shoudFetch) ? this.fetch(id) : this.get(id)!;
        let using = false;
        const CapturePromise = Promise.resolve(note)
            .catch(err => {
                console.error(err);
            })
            .finally(() => {
                this.capture(id);
                using = true;
            });

        const unuse = (noDeletion = false) => {
            CapturePromise.then(() => {
                if (!using) return;
                this.decapture(id, noDeletion);
                using = false;
            });
        };

        return {
            note,
            unuse,
        };
    }
}

export const noteManager = new NoteManager();

if (_DEV_) {
    console.log('entity manager initialized', {
        noteManager,
        userLiteManager,
        driveFileManager,
    });
}