import {
    MarkdownView,
    type App,
    type CachedMetadata,
    type Editor,
    type EditorPosition,
    type EventRef,
    type Events,
    type MarkdownViewModeType,
    type TAbstractFile,
    type TFile,
    type WorkspaceLeaf
} from "obsidian";

type ViewMode = MarkdownViewModeType | "live-preview" | null;

type EditorData = {
    file: TFile | null;
    cache: CachedMetadata | null;
    view: { view: MarkdownView | null; mode: ViewMode; };
    editor: Editor | undefined;
};

type MetadataCacheEvents = { changed: [file: TFile, data: string, cache: CachedMetadata]; };
type WorkspaceEvents = {
    "active-leaf-change": [leaf: WorkspaceLeaf | null];
    "file-open": [file: TFile | null];
    "layout-change": [];
};
type VaultEvents = { rename: [file: TAbstractFile, oldPath: string]; };
type ObsidianEvent = MetadataCacheEvents & WorkspaceEvents & VaultEvents;
type EventRegistration<M extends Record<string, any> = ObsidianEvent> = {
    [K in keyof M]: { name: K; callback: (...args: M[K]) => unknown; };
}[keyof M];

type RegisterReturnType = (eventRef: EventRef) => void;

const metadataCacheEventNames: ReadonlySet<keyof MetadataCacheEvents> = new Set([ "changed" ] as const);
const workspaceEventNames: ReadonlySet<keyof WorkspaceEvents> = new Set(
    [ "active-leaf-change", "file-open", "layout-change" ] as const
);
const vaultEventNames: ReadonlySet<keyof VaultEvents> = new Set([ "rename" ] as const);

function event<K extends keyof ObsidianEvent>(
    name: K,
    callback: (...args: ObsidianEvent[K]) => unknown
): EventRegistration {
    return { name, callback } as EventRegistration;
}

export default class EditorService {
    private app: App;
    private _registerEventHandle: RegisterReturnType;
    private builtinEvents: EventRegistration[] = [
        event("active-leaf-change", () => this.syncState()),
        event("file-open", (file) => this.setData(file)),
        event("layout-change", () => this.syncState())
    ];

    public file: TFile | null = null;
    public cache: CachedMetadata | null = null;
    public view: MarkdownView | null = null;
    public viewMode: ViewMode = null;
    public editorData: EditorData = {
        file: this.file,
        cache: this.cache,
        view: { view: this.view, mode: this.viewMode },
        editor: this.editor
    };

    constructor(app: App, registerEventHandle: RegisterReturnType, addonEvents: EventRegistration[]) {
        this.app = app;
        this._registerEventHandle = registerEventHandle;
        this.syncState();
        this.registerEvents(addonEvents);
    }

    public get editor(): Editor | undefined {
        return this.view?.editor;
    }

    public syncState(metadataCache?: CachedMetadata | null): void {
        const activeFile = this.app.workspace.getActiveFile();
        this.setFileData(activeFile, metadataCache);
        this.setViewData();
    }

    private getEmitter(name: keyof ObsidianEvent): Events {
        const isMetadataCacheEvent: boolean = metadataCacheEventNames.has(name as keyof MetadataCacheEvents);
        const isWorkspaceEvent: boolean = workspaceEventNames.has(name as keyof WorkspaceEvents);
        const isVaultEvent: boolean = vaultEventNames.has(name as keyof VaultEvents);

        if (isMetadataCacheEvent) return this.app.metadataCache;
        else if (isWorkspaceEvent) return this.app.workspace;
        else if (isVaultEvent) return this.app.vault;

        throw new Error(`Unknown event name: ${name}`);
    }

    private registerEvents(addonEvents: EventRegistration[]): void {
        const registered = new Set<string>();

        for (const { name, callback: addonCb } of addonEvents) {
            const builtin = this.builtinEvents.find((e) => e.name === name);
            const cb = addonCb as (...args: any[]) => unknown;
            const emitter = this.getEmitter(name);
            this.registerEvent(emitter.on(
                name,
                builtin
                    ? (...args: any[]) => {
                        (builtin.callback as (...args: any[]) => unknown)(...args);
                        cb(...args);
                    }
                    : cb
            ));
            registered.add(name as string);
        }

        for (const { name, callback } of this.builtinEvents) {
            if (registered.has(name as string)) continue;
            const emitter = this.getEmitter(name);
            this.registerEvent(emitter.on(name, callback as (...data: unknown[]) => unknown));
        }
    }

    private registerEvent(eventRef: EventRef): void {
        this._registerEventHandle(eventRef);
    }

    private setData(file: TFile | null): void {
        this.setFileData(file);
        this.setViewData();
    }

    private setViewData(): void {
        this.view = this.app.workspace.getActiveViewOfType(MarkdownView);
        const state = this.view?.getState();

        if (!state) {
            this.viewMode = null;
            return;
        }

        if (state.mode === "source") {
            this.viewMode = state.source === false ? "live-preview" : "source";
            return;
        }

        // state.mode === "preview"
        this.viewMode = "preview";
    }

    private setFileData(activeTFile: TFile | null, metadataCache?: CachedMetadata | null): void {
        this.file = activeTFile;
        this.cache = metadataCache !== undefined
            ? metadataCache
            : this.file
            ? this.app.metadataCache.getFileCache(this.file)
            : null;
    }

    public async applyEditorChange(
        from: EditorPosition,
        to: EditorPosition,
        insert: string,
        editor?: Editor
    ): Promise<void> {
        this.syncState();
        editor = editor ?? this.editor;
        const activeFile = this.file;
        const view = this.view;

        if (!editor || !activeFile || !view) {
            const message = `[WARN] ${
                !editor ? "Editor" : !activeFile
                    ? "File"
                    // !view
                    : "View"
            } not found in EditorService.applyEditorChange.`;
            new Notice(message);
            console.log(message);
            return;
        }

        const fromOffset = editor.posToOffset(from);
        const toOffset = editor.posToOffset(to);
        const cm = editor.cm;

        cm.dispatch({
            changes: { from: fromOffset, to: toOffset, insert },
            scrollIntoView: false // disable auto-scroll upon file edits
        });

        if (this.viewMode === "preview") {
            await this.app.vault.process(activeFile, (content) => {
                const lines = content.split("\n");

                let fromPos = 0;
                for (let i = 0; i < from.line && i < lines.length; i++) {
                    fromPos += lines[i].length + 1;
                }
                fromPos += from.ch;

                let toPos = 0;
                for (let i = 0; i < to.line && i < lines.length; i++) {
                    toPos += lines[i].length + 1;
                }
                toPos += to.ch;

                return content.slice(0, fromPos) + insert + content.slice(toPos);
            });

            view.previewMode.rerender(true);
        }
    }
}
