import type { CachedMetadata, Editor, EditorPosition, EditorRange, HeadingCache, SectionCache } from "obsidian";
import { Notice } from "obsidian";
import { deepMerge } from "./Utils";
import { instaTocCodeBlockId, localTocSettingsRegex } from "./constants";
import type EditorService from "./editorService";
import { getDefaultLocalSettings, type InstaTocSettings } from "./settings/Settings";
import { parseLocalTocSettingsYaml } from "./settings/localTocSettings";
import type { HeadingLevel, LocalTocSettings } from "./types";

/**
 * Type asserts that {@link SectionCache}[] is not undefined within the CachedMetadata type
 */
type ValidCacheType = CachedMetadata & { sections: SectionCache[]; };

/**
 * Type that represents a fully validated Validator instance
 */
type ValidatedInstaToc = {
    metadata: ValidCacheType;
    fileHeadings: HeadingCache[];
    instaTocSection: SectionCache;
    editor: Editor;
    cursorPos: EditorPosition;
    tocInsertPos: EditorRange;
    localTocSettings: LocalTocSettings;
};

function isInstaTocSection(section: SectionCache, editor: Editor): boolean {
    return (section.type === "code" && editor.getLine(section.position.start.line) === `\`\`\`${instaTocCodeBlockId}`);
}

// Finds and stores the instaTocSection
export function hasInstaTocSection(
    editor: Editor,
    sections: SectionCache[],
    instance: Validator
): instance is Validator & { metadata: ValidCacheType; instaTocSection: SectionCache; };
export function hasInstaTocSection(editor: Editor, sections: SectionCache[]): boolean;
export function hasInstaTocSection(editor: Editor, sections: SectionCache[], instance?: Validator): boolean {
    const instaTocSection: SectionCache | undefined = sections.find((section: SectionCache) =>
        isInstaTocSection(section, editor)
    );

    if (instaTocSection) {
        if (instance) instance.instaTocSection = instaTocSection;
        return true;
    }

    return false;
}

export function hasMultipleTocSections(editor: Editor, sections: SectionCache[]): boolean {
    const totalTocSections = sections.filter((section: SectionCache) => isInstaTocSection(section, editor));

    return totalTocSections.length > 1;
}

export class Validator {
    private settings: InstaTocSettings;
    private activeFilePath: string;
    private previousHeadings: HeadingCache[] = [];
    private previousLocalSettingsRaw = "";
    private cachedHeadingExcludePattern: RegExp | undefined;
    private cachedHeadingExcludePatternKey: string | undefined;

    public editorService: EditorService;
    public tocInsertPos!: EditorRange; // Assigned in this.isValid

    public fileHeadings: HeadingCache[];
    public localTocSettings: LocalTocSettings;
    public updatedLocalSettings: LocalTocSettings | undefined;
    public metadata: CachedMetadata;
    public instaTocSection!: SectionCache; // Assigned in this.isValid

    constructor(
        editorService: EditorService,
        settings: InstaTocSettings,
        metadata: CachedMetadata,
        activeFilePath: string
    ) {
        this.editorService = editorService;
        this.settings = settings;
        this.metadata = metadata;
        this.activeFilePath = activeFilePath;
        this.fileHeadings = [];
        this.localTocSettings = getDefaultLocalSettings();
    }

    // Method to update the validator properties while maintaining the previous state
    public update(
        editorService: EditorService,
        settings: InstaTocSettings,
        metadata: CachedMetadata,
        activeFilePath: string
    ): void {
        this.editorService = editorService;
        this.settings = settings;
        this.metadata = metadata;

        this.resetStateForFileSwitch(activeFilePath);
    }

    private resetStateForFileSwitch(activeFilePath: string): void {
        if (this.activeFilePath === activeFilePath) return;

        this.activeFilePath = activeFilePath;
        this.previousHeadings = [];
        this.previousLocalSettingsRaw = "";
        this.localTocSettings = getDefaultLocalSettings();
        this.updatedLocalSettings = undefined;
        this.fileHeadings = [];
    }

    private hasEditor(): this is this & { editorService: EditorService & { editor: Editor; }; } {
        return this.editorService.editor !== undefined;
    }

    private haveLocalSettingsChanged(): boolean {
        if (!this.hasEditor()) return false;
        const { editor } = this.editorService;

        const tocRange = editor.getRange(this.tocInsertPos.from, this.tocInsertPos.to);
        const tocData = tocRange.match(localTocSettingsRegex);
        const current = (tocData?.[1] ?? "").trim();

        if (current === this.previousLocalSettingsRaw) return false;

        this.previousLocalSettingsRaw = current;
        return true;
    }

    // Method to compare current headings with previous headings
    private haveHeadingsChanged(): boolean {
        const currentHeadings: HeadingCache[] = this.metadata.headings || [];
        const noPrevHeadings: boolean = this.previousHeadings.length === 0;
        const diffHeadingsLength: boolean = currentHeadings.length !== this.previousHeadings.length;

        const noHeadingsChange: boolean = noPrevHeadings || diffHeadingsLength
            ? false
            : currentHeadings.every((headingCache: HeadingCache, index: number) => {
                return (headingCache.heading === this.previousHeadings[index].heading
                    && headingCache.level === this.previousHeadings[index].level);
            });

        if (noHeadingsChange) return false;

        // Headings have changed, update previousHeadings
        this.previousHeadings = currentHeadings;

        return true;
    }

    // Type predicate to assert that metadata has headings and sections
    private hasSections(): this is Validator & { metadata: ValidCacheType; } {
        return !!this.metadata && !!this.metadata.sections;
    }

    // Finds and stores the instaTocSection
    // private hasInstaTocSection(): this is Validator & {
    //     metadata: ValidCacheType;
    //     instaTocSection: SectionCache;
    // } {
    //     if (!this.hasEditor() || !this.hasSections()) return false;
    //     const { editor } = this.editorService;

    //     const instaTocSection: SectionCache | undefined = this.metadata.sections.find(
    //         (section: SectionCache) => isInstaTocSection(section, editor)
    //     );

    //     if (instaTocSection) {
    //         this.instaTocSection = instaTocSection;
    //         return true;
    //     }

    //     return false;
    // }

    // Provides the insert location range for the new insta-toc codeblock
    private setTocInsertPos(): void {
        // Extract the start/end line/character index
        const startLine: number = this.instaTocSection.position.start.line;
        const startCh = 0;
        const endLine: number = this.instaTocSection.position.end.line;
        const endCh: number = this.instaTocSection.position.end.col;

        const tocStartPos: EditorPosition = { line: startLine, ch: startCh };
        const tocEndPos: EditorPosition = { line: endLine, ch: endCh };

        this.tocInsertPos = { from: tocStartPos, to: tocEndPos };
    }

    private configureLocalSettings(): void {
        if (!this.hasEditor()) return;
        const { editor } = this.editorService;

        const tocRange = editor.getRange(this.tocInsertPos.from, this.tocInsertPos.to);
        const tocData = tocRange.match(localTocSettingsRegex);

        if (!tocData) return;

        const [ , settingString ] = tocData;

        this.validateLocalSettings(settingString);
    }

    /** Only called from InstaToc class if local settings are applied */
    public applyLocalSettingsYaml(yml: string): boolean {
        const previousLocalSettings = deepMerge<LocalTocSettings>(getDefaultLocalSettings(), this.localTocSettings);
        const previousUpdatedSettings = this.updatedLocalSettings
            ? deepMerge<LocalTocSettings>(getDefaultLocalSettings(), this.updatedLocalSettings)
            : undefined;

        this.localTocSettings = getDefaultLocalSettings();
        this.updatedLocalSettings = undefined;

        const didApply = this.validateLocalSettings(yml);

        if (!didApply) {
            this.localTocSettings = previousLocalSettings;
            this.updatedLocalSettings = previousUpdatedSettings ?? previousLocalSettings;
            return false;
        }

        return true;
    }

    private validateLocalSettings(yml: string): boolean {
        const result = parseLocalTocSettingsYaml(yml);

        if (result.errors.length > 0) {
            const validationErrorMsg = "Invalid properties in insta-toc settings:\n" + result.errors.join("\n");

            console.error(validationErrorMsg);
            new Notice(validationErrorMsg);

            return false;
        }

        this.updatedLocalSettings = result.settings;
        this.localTocSettings = result.settings;

        return true;
    }

    private getHeadingExcludePattern(): RegExp | undefined {
        const cacheKey = JSON.stringify({
            excludedChars: this.settings.excludedChars,
            localExclude: this
                .localTocSettings
                .exclude
        });

        if (cacheKey === this.cachedHeadingExcludePatternKey) {
            return this.cachedHeadingExcludePattern;
        }

        const patterns: string[] = [];

        if (this.settings.excludedChars.length > 0) {
            const escapedGlobalChars = this.settings.excludedChars.map((char) => RegExp.escape(char)).join("");

            if (escapedGlobalChars.length > 0) {
                patterns.push(`[${escapedGlobalChars}]`);
            }
        }

        if (this.localTocSettings.exclude && this.localTocSettings.exclude.length > 0) {
            const excludeStr = this.localTocSettings.exclude;

            if (RegExp.isRegexPattern(excludeStr)) {
                patterns.push(`(${excludeStr.slice(1, -1)})`);
            }
            else {
                const escapedLocalChars = RegExp.escape(excludeStr);

                if (escapedLocalChars.length > 0) {
                    patterns.push(`[${escapedLocalChars}]`);
                }
            }
        }

        this.cachedHeadingExcludePattern = patterns.length > 0 ? new RegExp(patterns.join("|"), "g") : undefined;
        this.cachedHeadingExcludePatternKey = cacheKey;

        return this.cachedHeadingExcludePattern;
    }

    private setFileHeadings(): void {
        const headings: HeadingCache[] = this.metadata?.headings ?? [];
        const omit = new Set(this.localTocSettings.omit ?? []);
        const minLevel = this.localTocSettings.levels.min ?? 1;
        const maxLevel = this.localTocSettings.levels.max ?? 6;
        const headingExcludePattern = this.getHeadingExcludePattern();

        // Store the file headings to reference in later code
        this.fileHeadings = headings
            .filter((headingCache: HeadingCache) => {
                const headingText: string = headingCache.heading.trim();
                const headingLevel = headingCache.level as HeadingLevel;

                return (
                    /**
                     * Omit headings with "<!-- omit -->"
                     */
                    !headingText.match(/<!--\s*omit\s*-->/) /**
                     * Omit headings included within local "omit" setting
                     */
                    && !omit.has(headingText) /**
                     * Omit headings with levels outside of the specified local min/max setting
                     */
                    && headingLevel >= minLevel
                    && headingLevel <= maxLevel /**
                     * Omit empty headings
                     */
                    && headingText.trim().length > 0 /**
                     * Omit heading text specified in the global excluded heading text setting
                     */
                    && !this.settings.excludedHeadingText.includes(headingText) /**
                     * Omit heading levels specified in the global excluded heading levels setting
                     */
                    && !this.settings.excludedHeadingLevels.includes(headingLevel)
                );
            })
            .map((headingCache: HeadingCache) => {
                let modifiedHeading = headingCache.heading;
                if (headingExcludePattern) {
                    headingExcludePattern.lastIndex = 0;
                    modifiedHeading = modifiedHeading.replace(headingExcludePattern, "");
                }

                return { ...headingCache, heading: modifiedHeading };
            });
    }

    // Validates all conditions and asserts the type when true
    public isValid(forceRefresh = false): this is Validator & ValidatedInstaToc {
        if (
            !this.hasEditor()
            || !this.hasSections()
            || !hasInstaTocSection(this.editorService.editor, this.metadata.sections, this)
        ) return false;

        const hasMultipleTocs = hasMultipleTocSections(this.editorService.editor, this.metadata.sections);
        if (hasMultipleTocs) {
            const message = "[WARN] InstaToc section already present in the current file.";
            new Notice(message);
            console.log(message);
            return false;
        }

        this.setTocInsertPos();

        const headingsChanged = this.haveHeadingsChanged();
        const localSettingsChanged = this.haveLocalSettingsChanged();

        if (!forceRefresh && !headingsChanged && !localSettingsChanged) {
            return false;
        }

        this.configureLocalSettings();
        this.setFileHeadings();

        return true;
    }
}
