import protectedSessionHolder from "../services/protected_session_holder.js";
import server from "../services/server.js";
import utils from "../services/utils.js";
import appContext, { type EventData, type EventListener } from "./app_context.js";
import treeService from "../services/tree.js";
import Component from "./component.js";
import froca from "../services/froca.js";
import hoistedNoteService from "../services/hoisted_note.js";
import options from "../services/options.js";
import type { ViewScope } from "../services/link.js";
import type FNote from "../entities/fnote.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";

export interface SetNoteOpts {
    triggerSwitchEvent?: unknown;
    viewScope?: ViewScope;
}

export type GetTextEditorCallback = (editor: TextEditor) => void;

class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
    ntxId: string | null;
    hoistedNoteId: string;
    mainNtxId: string | null;

    notePath?: string | null;
    noteId?: string | null;
    parentNoteId?: string | null;
    viewScope?: ViewScope;

    constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
        super();

        this.ntxId = ntxId || NoteContext.generateNtxId();
        this.hoistedNoteId = hoistedNoteId;
        this.mainNtxId = mainNtxId;

        this.resetViewScope();
    }

    static generateNtxId() {
        return utils.randomString(6);
    }

    setEmpty() {
        this.notePath = null;
        this.noteId = null;
        this.parentNoteId = null;
        // hoisted note is kept intentionally

        this.triggerEvent("noteSwitched", {
            noteContext: this,
            notePath: this.notePath
        });

        this.resetViewScope();
    }

    isEmpty() {
        return !this.noteId;
    }

    async setNote(inputNotePath: string | undefined, opts: SetNoteOpts = {}) {
        opts.triggerSwitchEvent = opts.triggerSwitchEvent !== undefined ? opts.triggerSwitchEvent : true;
        opts.viewScope = opts.viewScope || {};
        opts.viewScope.viewMode = opts.viewScope.viewMode || "default";

        if (!inputNotePath) {
            return;
        }

        const resolvedNotePath = await this.getResolvedNotePath(inputNotePath);

        if (!resolvedNotePath) {
            return;
        }

        if (this.notePath === resolvedNotePath && utils.areObjectsEqual(this.viewScope, opts.viewScope)) {
            return;
        }

        await this.triggerEvent("beforeNoteSwitch", { noteContext: this });

        utils.closeActiveDialog();

        this.notePath = resolvedNotePath;
        this.viewScope = opts.viewScope;
        ({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath));

        this.saveToRecentNotes(resolvedNotePath);

        protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);

        if (opts.triggerSwitchEvent) {
            await this.triggerEvent("noteSwitched", {
                noteContext: this,
                notePath: this.notePath
            });
        }

        await this.setHoistedNoteIfNeeded();

        if (utils.isMobile()) {
            this.triggerCommand("setActiveScreen", { screen: "detail" });
        }
    }

    async setHoistedNoteIfNeeded() {
        if (this.hoistedNoteId === "root" && this.notePath?.startsWith("root/_hidden") && !this.note?.isLabelTruthy("keepCurrentHoisting")) {
            // hidden subtree displays only when hoisted, so it doesn't make sense to keep root as hoisted note

            let hoistedNoteId = "_hidden";

            if (this.note?.isLaunchBarConfig()) {
                hoistedNoteId = "_lbRoot";
            } else if (this.note?.isOptions()) {
                hoistedNoteId = "_options";
            }

            await this.setHoistedNoteId(hoistedNoteId);
        }
    }

    getSubContexts() {
        return appContext.tabManager.noteContexts.filter((nc) => nc.ntxId === this.ntxId || nc.mainNtxId === this.ntxId);
    }

    /**
     * A main context represents a tab and also the first split. Further splits are the children contexts of the main context.
     * Imagine you have a tab with 3 splits, each showing notes A, B, C (in this order).
     * In such a scenario, A context is the main context (also representing the tab as a whole), and B, C are the children
     * of context A.
     *
     * @returns {boolean} true if the context is main (= tab)
     */
    isMainContext() {
        // if null, then this is a main context
        return !this.mainNtxId;
    }

    /**
     * See docs for isMainContext() for better explanation.
     *
     * @returns {NoteContext}
     */
    getMainContext() {
        if (this.mainNtxId) {
            try {
                return appContext.tabManager.getNoteContextById(this.mainNtxId);
            } catch (e) {
                this.mainNtxId = null;
                return this;
            }
        } else {
            return this;
        }
    }

    saveToRecentNotes(resolvedNotePath: string) {
        setTimeout(async () => {
            // we include the note in the recent list only if the user stayed on the note at least 5 seconds
            if (resolvedNotePath && resolvedNotePath === this.notePath) {
                await server.post("recent-notes", {
                    noteId: this.note?.noteId,
                    notePath: this.notePath
                });
                utils.reloadTray();
            }
        }, 5000);
    }

    async getResolvedNotePath(inputNotePath: string) {
        const resolvedNotePath = await treeService.resolveNotePath(inputNotePath, this.hoistedNoteId);

        if (!resolvedNotePath) {
            logError(`Cannot resolve note path ${inputNotePath}`);
            return;
        }

        if ((await hoistedNoteService.checkNoteAccess(resolvedNotePath, this)) === false) {
            return; // note is outside of hoisted subtree and user chose not to unhoist
        }

        return resolvedNotePath;
    }

    get note(): FNote | null {
        if (!this.noteId || !(this.noteId in froca.notes)) {
            return null;
        }

        return froca.notes[this.noteId];
    }

    /** @returns {string[]} */
    get notePathArray() {
        return this.notePath ? this.notePath.split("/") : [];
    }

    isActive() {
        return appContext.tabManager.activeNtxId === this.ntxId;
    }

    getPojoState() {
        if (this.hoistedNoteId !== "root") {
            // keeping empty hoisted tab is esp. important for mobile (e.g. opened launcher config)

            if (!this.notePath && this.getSubContexts().length === 0) {
                return null;
            }
        }

        return {
            ntxId: this.ntxId,
            mainNtxId: this.mainNtxId,
            notePath: this.notePath,
            hoistedNoteId: this.hoistedNoteId,
            active: this.isActive(),
            viewScope: this.viewScope
        };
    }

    async unhoist() {
        await this.setHoistedNoteId("root");
    }

    async setHoistedNoteId(noteIdToHoist: string) {
        if (this.hoistedNoteId === noteIdToHoist) {
            return;
        }

        this.hoistedNoteId = noteIdToHoist;

        if (!this.notePathArray?.includes(noteIdToHoist)) {
            await this.setNote(noteIdToHoist);
        }

        await this.triggerEvent("hoistedNoteChanged", {
            noteId: noteIdToHoist,
            ntxId: this.ntxId
        });
    }

    /** @returns {Promise<boolean>} */
    async isReadOnly() {
        if (this?.viewScope?.readOnlyTemporarilyDisabled) {
            return false;
        }

        // "readOnly" is a state valid only for text/code notes
        if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
            return false;
        }

        if (this.note.isLabelTruthy("readOnly")) {
            return true;
        }

        if (this.viewScope?.viewMode === "source") {
            return true;
        }

        const blob = await this.note.getBlob();
        if (!blob) {
            return false;
        }

        const sizeLimit = this.note.type === "text" ? options.getInt("autoReadonlySizeText") : options.getInt("autoReadonlySizeCode");

        return sizeLimit && blob.contentLength > sizeLimit && !this.note.isLabelTruthy("autoReadOnlyDisabled");
    }

    async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
        if (this.noteId && loadResults.isNoteReloaded(this.noteId)) {
            const noteRow = loadResults.getEntityRow("notes", this.noteId);

            if (noteRow.isDeleted) {
                this.noteId = null;
                this.notePath = null;

                this.triggerEvent("noteSwitched", {
                    noteContext: this,
                    notePath: this.notePath
                });
            }
        }
    }

    hasNoteList() {
        return (
            this.note &&
            ["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "") &&
            (this.note.hasChildren() || this.note.getLabelValue("viewType") === "calendar") &&
            ["book", "text", "code"].includes(this.note.type) &&
            this.note.mime !== "text/x-sqlite;schema=trilium" &&
            !this.note.isLabelTruthy("hideChildrenOverview")
        );
    }

    async getTextEditor(callback?: GetTextEditorCallback) {
        return this.timeout<TextEditor>(
            new Promise((resolve) =>
                appContext.triggerCommand("executeWithTextEditor", {
                    callback,
                    resolve,
                    ntxId: this.ntxId
                })
            )
        );
    }

    async getCodeEditor() {
        return this.timeout(
            new Promise<CodeMirrorInstance>((resolve) =>
                appContext.triggerCommand("executeWithCodeEditor", {
                    resolve,
                    ntxId: this.ntxId
                })
            )
        );
    }

    /**
     * Returns a promise which will retrieve the JQuery element of the content of this note context.
     *
     * Do note that retrieving the content element needs to be handled by the type widget, which is the one which
     * provides the content element by listening to the `executeWithContentElement` event. Not all note types support
     * this.
     *
     * If no content could be determined `null` is returned instead.
     */
    async getContentElement() {
        return this.timeout<JQuery<HTMLElement>>(
            new Promise((resolve) =>
                appContext.triggerCommand("executeWithContentElement", {
                    resolve,
                    ntxId: this.ntxId
                })
            )
        );
    }

    async getTypeWidget() {
        return this.timeout(
            new Promise<TypeWidget | null>((resolve) =>
                appContext.triggerCommand("executeWithTypeWidget", {
                    resolve,
                    ntxId: this.ntxId
                })
            )
        );
    }

    timeout<T>(promise: Promise<T | null>) {
        return Promise.race([promise, new Promise((res) => setTimeout(() => res(null), 200))]) as Promise<T>;
    }

    resetViewScope() {
        // view scope contains data specific to one note context and one "view".
        // it is used to e.g., make read-only note temporarily editable or to hide TOC
        // this is reset after navigating to a different note
        this.viewScope = {};
    }

    async getNavigationTitle() {
        if (!this.note) {
            return null;
        }

        const { note, viewScope } = this;

        const isNormalView = (viewScope?.viewMode === "default" || viewScope?.viewMode === "contextual-help");
        let title = (isNormalView ? note.title : `${note.title}: ${viewScope?.viewMode}`);

        if (viewScope?.attachmentId) {
            // assuming the attachment has been already loaded
            const attachment = await note.getAttachmentById(viewScope.attachmentId);

            if (attachment) {
                title += `: ${attachment.title}`;
            }
        }

        return title;
    }
}

export default NoteContext;
