import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "@/common/events.js";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { scheduleTask } from "octagonal-wheels/concurrency/task";
import type { TFile } from "@/deps.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import { type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
import {
    collectingChunks,
    pluginScanningCount,
    hiddenFilesEventCount,
    hiddenFilesProcessingCount,
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import type { LiveSyncCore } from "@/main.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";

type MutableCommandDefinition = {
    callback?: () => void;
};

type InternalCommandRegistry = {
    commands?: Record<string, MutableCommandDefinition | undefined>;
    executeCommandById(commandId: string): unknown;
};

type AppWithInternalCommands = {
    commands?: InternalCommandRegistry;
};

type CodeMirrorAdapter = {
    commands: { save: () => void };
};

export class ModuleObsidianEvents extends AbstractObsidianModule {
    _everyOnloadStart(): Promise<boolean> {
        // this.registerEvent(this.app.workspace.on("editor-change", ));
        this.plugin.registerEvent(
            this.app.vault.on("rename", (file, oldPath) => {
                eventHub.emitEvent(EVENT_FILE_RENAMED, {
                    newPath: file.path as FilePathWithPrefix,
                    old: oldPath as FilePathWithPrefix,
                });
            })
        );
        this.plugin.registerEvent(
            this.app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED))
        );
        return Promise.resolve(true);
    }

    __performAppReload() {
        this.services.appLifecycle.performRestart();
    }

    initialCallback: (() => void) | undefined = undefined;

    swapSaveCommand() {
        this._log("Modifying callback of the save command", LOG_LEVEL_VERBOSE);
        const commandRegistry = (this.app as unknown as AppWithInternalCommands).commands;
        const saveCommandDefinition = commandRegistry?.commands?.["editor:save-file"];
        const save = saveCommandDefinition?.callback;
        if (saveCommandDefinition && typeof save === "function") {
            this.initialCallback = save;
            saveCommandDefinition.callback = () => {
                scheduleTask("syncOnEditorSave", 250, () => {
                    if (this.services.control.hasUnloaded()) {
                        this._log("Unload and remove the handler.", LOG_LEVEL_VERBOSE);
                        saveCommandDefinition.callback = this.initialCallback;
                        this.initialCallback = undefined;
                    } else {
                        if (this.settings.syncOnEditorSave) {
                            this._log("Sync on Editor Save.", LOG_LEVEL_VERBOSE);
                            fireAndForget(() =>
                                this.services.replication.replicateUnattendedByEvent({
                                    trigger: "editor-save",
                                    interaction: NO_INTERACTION,
                                })
                            );
                        }
                    }
                });
                save();
            };
        }
        const codeMirrorAdapter = (compatGlobal as typeof compatGlobal & { CodeMirrorAdapter?: CodeMirrorAdapter })
            .CodeMirrorAdapter;
        if (!codeMirrorAdapter) {
            this._log("CodeMirrorAdapter is not available");
            return;
        }
        codeMirrorAdapter.commands.save = () => {
            void commandRegistry?.executeCommandById("editor:save-file");
            // _this.app.performCommand('editor:save-file');
        };
    }

    registerWatchEvents() {
        this.setHasFocus = this.setHasFocus.bind(this);
        this.watchWindowVisibility = this.watchWindowVisibility.bind(this);
        this.watchWorkspaceOpen = this.watchWorkspaceOpen.bind(this);
        this.watchOnline = this.watchOnline.bind(this);
        // Already bound
        // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration.
        this.plugin.registerEvent(this.app.workspace.on("file-open", this.watchWorkspaceOpen));
        // Already bound
        // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration.
        this.plugin.registerDomEvent(activeDocument, "visibilitychange", this.watchWindowVisibility);
        this.plugin.registerDomEvent(compatGlobal, "focus", () => this.setHasFocus(true));
        this.plugin.registerDomEvent(compatGlobal, "blur", () => this.setHasFocus(false));
        // Already bound
        // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration.
        this.plugin.registerDomEvent(compatGlobal, "online", this.watchOnline);
        // Already bound
        // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration.
        this.plugin.registerDomEvent(compatGlobal, "offline", this.watchOnline);
    }

    hasFocus = true;
    isLastHidden = false;
    private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
    private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";

    private get boundedActivityCounts(): ReactiveSource<number>[] {
        const replicator = this.services.replicator as typeof this.services.replicator & {
            boundedLocalApplicationActivityCount: ReactiveSource<number>;
        };
        return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
    }

    private hasBoundedActivity() {
        return this.boundedActivityCounts.some((count) => count.value > 0);
    }

    private keepReplicationActiveInBackground() {
        return (
            this.settings.keepReplicationActiveInBackground &&
            (this.settings.liveSync || this.settings.periodicReplication) &&
            !this.services.API.isMobile()
        );
    }

    private async applyDeferredBoundedActivityLifecycle() {
        if (this.hasBoundedActivity()) {
            this.deferLifecycleUntilBoundedActivityEnds();
            return;
        }
        const deferredLifecycle = this.deferredBoundedLifecycle;
        this.deferredBoundedLifecycle = undefined;
        const keepActiveInBackground = this.keepReplicationActiveInBackground();
        if (deferredLifecycle === "suspend-if-hidden" && activeWindow.document.hidden) {
            if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending();
            return;
        }
        if (
            deferredLifecycle === "restart-continuous-if-visible" &&
            !activeWindow.document.hidden &&
            keepActiveInBackground &&
            this.settings.liveSync
        ) {
            await this.services.appLifecycle.onSuspending();
            await this.services.appLifecycle.onResuming();
            await this.services.appLifecycle.onResumed();
        }
    }

    private deferLifecycleUntilBoundedActivityEnds() {
        if (this.boundedActivityEndHandler) return;
        const counts = this.boundedActivityCounts;
        const handler = () => {
            if (this.hasBoundedActivity()) return;
            for (const count of counts) count.offChanged(handler);
            this.boundedActivityEndHandler = undefined;
            fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
        };
        this.boundedActivityEndHandler = handler;
        for (const count of counts) count.onChanged(handler);
    }

    setHasFocus(hasFocus: boolean) {
        this.hasFocus = hasFocus;
        this.watchWindowVisibility();
    }

    watchWindowVisibility() {
        scheduleTask("watch-window-visibility", 100, () => fireAndForget(() => this.watchWindowVisibilityAsync()));
    }

    watchOnline() {
        scheduleTask("watch-online", 500, () => fireAndForget(() => this.watchOnlineAsync()));
    }
    async watchOnlineAsync() {
        // If some files were failed to retrieve, scan files again.
        // TODO:FIXME AT V0.17.31, this logic has been disabled.
        if (compatGlobal.navigator.onLine && this.localDatabase.needScanning) {
            this.localDatabase.needScanning = false;
            await this.services.vault.scanVault();
        }
    }

    async watchWindowVisibilityAsync() {
        if (this.settings.suspendFileWatching) {
            if (this.settings.isConfigured && this.services.appLifecycle.isReady() && this.hasBoundedActivity()) {
                const isHidden = activeWindow.document.hidden;
                this.isLastHidden = isHidden;
                this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
                this.deferLifecycleUntilBoundedActivityEnds();
            }
            return;
        }
        if (!this.settings.isConfigured) return;
        if (!this.services.appLifecycle.isReady()) return;

        if (this.isLastHidden && !this.hasFocus) {
            // NO OP while non-focused after made hidden;
            return;
        }

        const isHidden = activeWindow.document.hidden;
        if (this.isLastHidden === isHidden) {
            return;
        }

        const boundedActivityInProgress = this.hasBoundedActivity();
        if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
            this.isLastHidden = false;
            this.deferredBoundedLifecycle = undefined;
            return;
        }
        this.isLastHidden = isHidden;

        await this.services.fileProcessing.commitPendingFileEvents();

        // Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
        // window is hidden, instead of suspending on hide. On hide we skip the suspend for both
        // modes (LiveSync's continuous replication and Periodic's timer both stall otherwise);
        // becoming visible reopens normally, and for LiveSync additionally forces a teardown first
        // (see the resume branch) so a stalled continuous channel is always replaced.
        const keepActiveInBackground = this.keepReplicationActiveInBackground();

        if (isHidden) {
            if (boundedActivityInProgress && !keepActiveInBackground) {
                this.deferredBoundedLifecycle = "suspend-if-hidden";
                this.deferLifecycleUntilBoundedActivityEnds();
            } else if (!keepActiveInBackground) {
                await this.services.appLifecycle.onSuspending();
            }
        } else {
            // suspend all temporary.
            if (this.services.appLifecycle.isSuspended()) return;
            if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
                this.deferredBoundedLifecycle = "restart-continuous-if-visible";
                this.deferLifecycleUntilBoundedActivityEnds();
                return;
            }
            // Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
            // emits paused/retry while the replicator keeps its AbortController set, so the reopen
            // below would no-op on exactly the channel that needs replacing. Force a teardown first
            // so becoming visible always re-establishes a fresh channel (restoring the default's
            // reset-on-visibility). Periodic mode has no such channel — its timer just resumes via
            // the normal path below — so this teardown is gated on liveSync to avoid needlessly
            // bouncing it. The teardown's closeReplication() aborts synchronously while the reopen is
            // deferred (fireAndForget + awaited isReplicationReady/initializeDatabaseForReplication),
            // so the aborted continuousReplication run (and its shareRunningResult lock) unwinds in
            // microtasks before the reopen runs: it neither double-opens nor gets swallowed by the
            // still-registered shared run.
            if (keepActiveInBackground && this.settings.liveSync) {
                await this.services.appLifecycle.onSuspending();
            }
            // Resume is not gated on focus in this branch, but note the top-of-handler check
            // (isLastHidden && !hasFocus) still defers the whole handler when the window becomes
            // visible again while unfocused; in that case recovery happens on the next focus.
            await this.services.appLifecycle.onResuming();
            await this.services.appLifecycle.onResumed();
        }
    }
    watchWorkspaceOpen(file: TFile | null) {
        if (this.settings.suspendFileWatching) return;
        if (!this.settings.isConfigured) return;
        if (!this.services.appLifecycle.isReady()) return;
        if (!file) return;
        scheduleTask("watch-workspace-open", 500, () => fireAndForget(() => this.watchWorkspaceOpenAsync(file)));
    }

    async watchWorkspaceOpenAsync(file: TFile) {
        if (this.settings.suspendFileWatching) return;
        if (!this.settings.isConfigured) return;
        if (!this.services.appLifecycle.isReady()) return;
        await this.services.fileProcessing.commitPendingFileEvents();
        if (file == null) {
            return;
        }
        if (this.settings.syncOnFileOpen && !this.services.appLifecycle.isSuspended()) {
            await this.services.replication.replicateUnattendedByEvent({
                trigger: "file-open",
                interaction: NO_INTERACTION,
            });
        }
        await this.services.conflict.queueCheckForIfOpen(file.path as FilePathWithPrefix);
    }

    _everyOnLayoutReady(): Promise<boolean> {
        this.swapSaveCommand();
        this.registerWatchEvents();
        return Promise.resolve(true);
    }

    private _askReload(message?: string) {
        if (this.services.appLifecycle.isReloadingScheduled()) {
            this._log(`Reloading is already scheduled`, LOG_LEVEL_VERBOSE);
            return;
        }
        scheduleTask("configReload", 250, async () => {
            const RESTART_NOW = "Yes, restart immediately";
            const RESTART_AFTER_STABLE = "Yes, schedule a restart after stabilisation";
            const RETRY_LATER = "No, Leave it to me";
            const ret = await this.core.confirm.askSelectStringDialogue(
                message || "Do you want to restart and reload Obsidian now?",
                [RESTART_AFTER_STABLE, RESTART_NOW, RETRY_LATER],
                { defaultAction: RETRY_LATER }
            );
            if (ret == RESTART_NOW) {
                this.__performAppReload();
            } else if (ret == RESTART_AFTER_STABLE) {
                this.services.appLifecycle.scheduleRestart();
            }
        });
    }

    // Process counting for app reload scheduling
    _totalProcessingCount?: ReactiveSource<number> = undefined;
    private _scheduleAppReload() {
        if (!this._totalProcessingCount) {
            const __tick = reactiveSource(0);
            this._totalProcessingCount = reactive(() => {
                const dbCount = this.services.replication.databaseQueueCount.value;
                const replicationCount = this.services.replication.replicationResultCount.value;
                const storageApplyingCount = this.services.replication.storageApplyingCount.value;
                const chunkCount = collectingChunks.value;
                const pluginScanCount = pluginScanningCount.value;
                const hiddenFilesCount = hiddenFilesEventCount.value + hiddenFilesProcessingCount.value;
                const conflictProcessCount = this.services.conflict.conflictProcessQueueCount.value;
                // Now no longer `pendingFileEventCount` and `processingFileEventCount` is used
                // const e = this.core.pendingFileEventCount.value;
                // const proc = this.core.processingFileEventCount.value;
                const e = 0;
                const proc = 0;
                // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Reading the tick establishes the reactive polling dependency.
                const __ = __tick.value;
                return (
                    dbCount +
                    replicationCount +
                    storageApplyingCount +
                    chunkCount +
                    pluginScanCount +
                    hiddenFilesCount +
                    conflictProcessCount +
                    e +
                    proc
                );
            });
            this.plugin.registerInterval(
                compatGlobal.setInterval(() => {
                    __tick.value++;
                }, 1000)
            );

            let stableCheck = 3;
            this._totalProcessingCount.onChanged((e) => {
                if (e.value == 0) {
                    if (stableCheck-- <= 0) {
                        this.__performAppReload();
                    }
                    this._log(
                        `Obsidian will be restarted soon! (Within ${stableCheck} seconds)`,
                        LOG_LEVEL_NOTICE,
                        "restart-notice"
                    );
                } else {
                    stableCheck = 3;
                }
            });
        }
    }
    _isReloadingScheduled(): boolean {
        return this._totalProcessingCount !== undefined;
    }
    override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
        services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
        services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
        services.appLifecycle.askRestart.setHandler(this._askReload.bind(this));
        services.appLifecycle.scheduleRestart.setHandler(this._scheduleAppReload.bind(this));
        services.appLifecycle.isReloadingScheduled.setHandler(this._isReloadingScheduled.bind(this));
    }
}
