import {
    TFile,
    type CachedMetadata,
    type Editor,
    type EditorPosition,
    type HeadingCache,
    type MarkdownView
} from "obsidian";
import { vi, type Mock } from "vitest";
import type InstaTocPlugin from "../../src/Plugin";
import type EditorService from "../../src/editorService";
import type { PluginSettingsManager } from "../../src/settings/PluginSettingManager";
import { DEFAULT_SETTINGS, type InstaTocSettings } from "../../src/settings/Settings";
import type { FileKey, FoldKey, LocalTocSettings } from "../../src/types";
import UiStateManager from "../../src/uiStateManager";
import type { Validator } from "../../src/validator";

type TestViewMode = "preview" | "source" | "live-preview";

type EditorDispatchSpec = { changes: { from: number; to: number; insert: string; }; scrollIntoView?: boolean; };

type TestEditor = Editor & {
    cm: {
        dispatch: (spec: EditorDispatchSpec) => void;
        posToOffset: (pos: EditorPosition) => number;
        replaceRange: (replacement: string, from: EditorPosition, to: EditorPosition, origin?: string) => void;
    };
    getValue: () => string;
    setValue: (content: string) => void;
    getCursor: () => EditorPosition;
    setCursor: (position: EditorPosition) => void;
    posToOffset: (pos: EditorPosition) => number;
    replaceRange: (replacement: string, from: EditorPosition, to: EditorPosition, origin?: string) => void;
};

type PersistedUiStateOverrides = {
    tocFoldState?: Map<FoldKey, boolean> | Partial<Record<FoldKey, boolean>>;
    tocBlockCollapseState?: Map<FileKey, boolean> | Partial<Record<FileKey, boolean>>;
};

function normalizePersistedUiStateMap<TKey extends string>(
    input: Map<TKey, boolean> | Partial<Record<TKey, boolean>> | undefined
): Map<TKey, boolean> {
    if (input instanceof Map) {
        return new Map(input);
    }

    const state = new Map<TKey, boolean>();

    for (const [ key, value ] of Object.entries(input ?? {})) {
        if (typeof value === "boolean") {
            state.set(key as TKey, value);
        }
    }

    return state;
}

function getOffset(content: string, pos: EditorPosition): number {
    const lines = content.split("\n");
    let offset = 0;

    for (let line = 0; line < pos.line; line += 1) {
        offset += (lines[line] ?? "").length + 1;
    }

    return offset + pos.ch;
}

function replaceContent(content: string, replacement: string, from: EditorPosition, to: EditorPosition): string {
    const startOffset = getOffset(content, from);
    const endOffset = getOffset(content, to);

    return content.slice(0, startOffset) + replacement + content.slice(endOffset);
}

function getEditorText(editor: Editor): string {
    return (editor as Partial<TestEditor>).getValue?.() ?? "";
}

function setEditorText(editor: Editor, content: string): void {
    (editor as Partial<TestEditor>).setValue?.(content);
}

function createFile(path: string): TFile {
    return Object.assign(new TFile(), { path });
}

export function createUiStateManagerMock(
    overrides: PersistedUiStateOverrides = {}
): { savePersistedDataSpy: Mock<() => Promise<undefined>>; uiStateManager: UiStateManager; } {
    const savePersistedDataSpy = vi.fn(async () => undefined);
    const settingsManager = { savePersistedData: savePersistedDataSpy } as unknown as PluginSettingsManager;
    const uiStateManager = new UiStateManager(settingsManager);

    uiStateManager.setPersistedUiState(
        normalizePersistedUiStateMap(overrides.tocFoldState),
        normalizePersistedUiStateMap(overrides.tocBlockCollapseState)
    );

    return { savePersistedDataSpy, uiStateManager };
}

function createEditorChangeApplier(getEditor: () => Editor) {
    return async (from: EditorPosition, to: EditorPosition, insert: string): Promise<void> => {
        const editor = getEditor();
        const content = getEditorText(editor);
        const updated = replaceContent(content, insert, from, to);
        setEditorText(editor, updated);
    };
}

export function createPluginMock(
    overrides?: Partial<InstaTocPlugin["settings"]>,
    initialEditor: Editor = createEditor([])
): {
    plugin: InstaTocPlugin;
    getCapturedContent: () => string;
    setEditor: (editor: Editor) => void;
    setMetadataCache: (cache: CachedMetadata | null) => void;
    setActiveFilePath: (path: string) => void;
    setViewMode: (viewMode: TestViewMode) => void;
} {
    let activeEditor = initialEditor;
    let activeFile = createFile("test.md");
    let activeMetadataCache: CachedMetadata | null = null;
    let activeViewMode: TestViewMode = "live-preview";
    const { uiStateManager } = createUiStateManagerMock();

    const activeView = Object.assign({} as MarkdownView, {
        get data(): string {
            return getEditorText(activeEditor);
        },
        get editor(): Editor {
            return activeEditor;
        },
        getState(): { mode: "preview" | "source"; source: boolean; } {
            if (activeViewMode === "preview") {
                return { mode: "preview", source: false };
            }

            return { mode: "source", source: activeViewMode === "live-preview" };
        },
        previewMode: {
            rerender: vi.fn(),
            renderer: {
                set: (text: string) => {
                    setEditorText(activeEditor, text);
                }
            }
        }
    });

    const applyEditorChange = createEditorChangeApplier(() => activeEditor);

    const workspace = {
        activeEditor: {
            get editor(): Editor {
                return activeEditor;
            },
            set editor(editor: Editor) {
                activeEditor = editor;
            }
        },
        getActiveFile: () => activeFile,
        getActiveViewOfType: () => activeView
    };

    const editorService = {
        get editor(): Editor {
            return workspace.activeEditor.editor;
        },
        get file(): TFile {
            return workspace.getActiveFile();
        },
        get cache(): CachedMetadata | null {
            return activeMetadataCache;
        },
        get view(): MarkdownView {
            return workspace.getActiveViewOfType();
        },
        get viewMode(): TestViewMode {
            return activeViewMode;
        },
        syncState: vi.fn((cache?: CachedMetadata | null) => {
            if (cache !== undefined) {
                activeMetadataCache = cache;
            }
        }),
        applyEditorChange
    } as unknown as EditorService;

    const plugin = {
        settings: { ...DEFAULT_SETTINGS, ...overrides },
        consoleDebug: console.debug,
        app: {
            workspace,
            metadataCache: {
                getFileCache: vi.fn((file: TFile) => {
                    return file.path === activeFile.path ? activeMetadataCache : null;
                }),
                fileToLinktext: vi.fn((_file: TFile, path: string) => path.replace(/\.md$/u, ""))
            },
            vault: {
                getAbstractFileByPath: vi.fn((path: string) => {
                    return path === activeFile.path
                        ? activeFile
                        : createFile(path);
                }),
                process: vi.fn(async (_file: unknown, fn: (content: string) => string) => {
                    setEditorText(activeEditor, fn(getEditorText(activeEditor)));
                }),
                read: vi.fn(async () => getEditorText(activeEditor))
            }
        },
        uiStateManager,
        getViewState() {
            return "live-preview";
        },
        get editor(): Editor {
            return workspace.activeEditor.editor;
        },
        editorService,
        applyEditorChange
    } as unknown as InstaTocPlugin;

    return {
        plugin,
        getCapturedContent: () => getEditorText(activeEditor),
        setEditor: (editor: Editor) => {
            workspace.activeEditor.editor = editor;
        },
        setMetadataCache: (cache: CachedMetadata | null) => {
            activeMetadataCache = cache;
        },
        setActiveFilePath: (path: string) => {
            activeFile = createFile(path);
        },
        setViewMode: (viewMode: TestViewMode) => {
            activeViewMode = viewMode;
        }
    };
}

export function createTocModelPluginMock(
    settingsOverrides: Partial<InstaTocSettings> = {},
    persistedUiState: PersistedUiStateOverrides = {}
): {
    app: InstaTocPlugin["app"];
    fileToLinktextSpy: Mock<(_file: TFile, path: string) => string>;
    getAbstractFileByPathSpy: Mock<(path: string) => TFile>;
    pruneSpy: Mock<
        (sourcePath: string, opts: { replacementFile?: FileKey; activeModernFoldKeys?: Set<FoldKey>; }) => void
    >;
    savePersistedDataSpy: Mock<() => Promise<undefined>>;
    settings: InstaTocSettings;
    uiStateManager: UiStateManager;
} {
    const { savePersistedDataSpy, uiStateManager } = createUiStateManagerMock(persistedUiState);
    const pruneSpy = vi.spyOn(uiStateManager, "pruneTocFoldStateForPath");
    const getAbstractFileByPathSpy = vi.fn((path: string) => createFile(path));
    const fileToLinktextSpy = vi.fn((_file: TFile, path: string) => path.replace(/\.md$/u, ""));
    const settings: InstaTocSettings = { ...DEFAULT_SETTINGS, ...settingsOverrides };
    const app = {
        vault: { getAbstractFileByPath: getAbstractFileByPathSpy },
        metadataCache: { fileToLinktext: fileToLinktextSpy }
    } as unknown as InstaTocPlugin["app"];

    return {
        app,
        fileToLinktextSpy,
        getAbstractFileByPathSpy,
        pruneSpy,
        savePersistedDataSpy,
        settings,
        uiStateManager
    };
}

export function createValidatorMock(
    localTocSettings: LocalTocSettings,
    fileHeadings: HeadingCache[]
): { validator: Validator; } {
    const validator = {
        localTocSettings,
        fileHeadings,
        tocInsertPos: { from: { line: 0, ch: 0 }, to: { line: 0, ch: 0 } },
        insureLocalTocSetting(
            settingKey: keyof LocalTocSettings,
            subKeyOrCb?: string | ((value: unknown) => unknown),
            cbOrDefault?: ((value: unknown) => unknown) | unknown,
            defaultVal?: unknown
        ): unknown {
            const value = localTocSettings[settingKey];

            if (typeof subKeyOrCb === "function") {
                if (value === null || value === undefined) {
                    return cbOrDefault !== undefined ? cbOrDefault : null;
                }

                return subKeyOrCb(value);
            }

            if (typeof subKeyOrCb === "string") {
                const subValue = (value as Record<string, unknown>)?.[subKeyOrCb];

                if (typeof cbOrDefault === "function") {
                    if (subValue === null || subValue === undefined) {
                        return defaultVal !== undefined ? defaultVal : null;
                    }

                    return cbOrDefault(subValue);
                }

                return subValue === undefined ? null : subValue;
            }

            return value ?? null;
        }
    } as unknown as Validator;

    return { validator };
}

export function createEditor(lines: string[]): Editor {
    let capturedContent = lines.join("\n");
    let cursorPos: EditorPosition = { line: 0, ch: 0 };

    const posToOffset = (pos: EditorPosition): number => {
        return getOffset(capturedContent, pos);
    };

    const replaceRange = (replacement: string, from: EditorPosition, to: EditorPosition, _origin?: string): void => {
        capturedContent = replaceContent(capturedContent, replacement, from, to);
    };

    const editor = {
        cm: {
            dispatch: (spec: EditorDispatchSpec) => {
                capturedContent = [
                    capturedContent.slice(0, spec.changes.from),
                    spec.changes.insert,
                    capturedContent.slice(spec.changes.to)
                ]
                    .join("");
            },
            posToOffset,
            replaceRange
        },
        getValue(): string {
            return capturedContent;
        },
        setValue(content: string): void {
            capturedContent = content;
        },
        getCursor(): EditorPosition {
            return cursorPos;
        },
        setCursor(position: EditorPosition): void {
            cursorPos = position;
        },
        posToOffset,
        replaceRange,
        getLine(line: number): string {
            return capturedContent.split("\n")[line] ?? "";
        },
        getRange(from: EditorPosition, to: EditorPosition): string {
            const startOffset = posToOffset(from);
            const endOffset = posToOffset(to);

            return capturedContent.slice(startOffset, endOffset);
        }
    } as unknown as TestEditor;

    return editor as unknown as Editor;
}
