/*
This file is part of the Notesnook project (https://notesnook.com/)

Copyright (C) 2023 Streetwriters (Private) Limited

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/
import { Extension } from "@tiptap/core";
import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
const updateView = (state, dispatch) => {
    if (!state.tr)
        return;
    dispatch(state.tr);
};
const regex = (s, settings) => {
    const { enableRegex, matchCase, matchWholeWord } = settings;
    const boundary = matchWholeWord ? "\\b" : "";
    try {
        return RegExp(boundary +
            (enableRegex ? s : s.replace(/[/\\^$*+?.()|[\]]/g, "\\$&")) +
            boundary, matchCase ? "gum" : "guim");
    }
    catch (e) {
        console.error(e);
    }
};
function searchDocument(tr, searchResultClass, searchTerm, selectedIndex) {
    if (!searchTerm)
        return {
            decorationSet: DecorationSet.empty,
            results: [],
            startIndex: selectedIndex || 0
        };
    const doc = tr.doc;
    const results = [];
    let index = -1;
    const textNodesWithPosition = [];
    let cursor = 0;
    doc === null || doc === void 0 ? void 0 : doc.descendants((node, pos) => {
        var _a, _b, _c;
        if (node.isText) {
            if (textNodesWithPosition[index]) {
                textNodesWithPosition[index].text += node.text;
                textNodesWithPosition[index].end = cursor + (((_a = node.text) === null || _a === void 0 ? void 0 : _a.length) || 0);
            }
            else {
                textNodesWithPosition[index] = {
                    text: node.text || "",
                    startPos: pos,
                    endPos: pos + node.nodeSize,
                    start: cursor,
                    end: cursor + (((_b = node.text) === null || _b === void 0 ? void 0 : _b.length) || 0)
                };
            }
            cursor += ((_c = node.text) === null || _c === void 0 ? void 0 : _c.length) || 0;
        }
        else if (node.isBlock) {
            const lastNode = textNodesWithPosition[index];
            if (lastNode) {
                lastNode.text += "\n";
                lastNode.end++;
                lastNode.endPos = pos;
                cursor++;
            }
            index++;
        }
    });
    const text = textNodesWithPosition.map((c) => c.text).join("");
    for (const match of text.matchAll(searchTerm)) {
        const start = match.index;
        const end = match.index + match[0].length;
        // Gets all matching nodes that have either the start or end of the
        // search term in them. This adds support for multi line regex searches.
        const nodes = textNodesWithPosition.filter((node) => {
            const nodeStart = node.start;
            const nodeEnd = node.end;
            return ((start >= nodeStart && start < nodeEnd) ||
                (end >= nodeStart && end < nodeEnd));
        });
        if (!nodes.length)
            continue;
        const endNode = nodes[nodes.length - 1];
        const startNode = nodes[0];
        results.push({
            // reposition our RegExp match index relative to the actual node.
            from: start + (startNode.startPos - startNode.start),
            to: end + (endNode.endPos - endNode.end)
        });
    }
    const { from: selectedFrom, to: selectedTo } = tr.selection;
    for (let i = 0; i < results.length; i++) {
        const { from, to } = results[i];
        if (
        // if a result is already selected, persist it
        (selectedFrom === from && to === selectedTo) ||
            // otherwise select the first matching result after the selection
            from >= selectedFrom) {
            selectedIndex = i;
            break;
        }
    }
    return {
        startIndex: selectedIndex || 0,
        decorationSet: DecorationSet.create(doc, resultsToDecorations(results, searchResultClass, selectedIndex)),
        results
    };
}
function resultsToDecorations(results, searchResultClass, selectedIndex) {
    const decorations = [];
    for (let i = 0; i < results.length; i++) {
        const { from, to } = results[i];
        const resultClass = i === selectedIndex ? `${searchResultClass} selected` : searchResultClass;
        decorations.push(Decoration.inline(from, to, { class: resultClass }));
    }
    return decorations;
}
const replaceAll = (replaceTerm, results, tr) => {
    if (!results.length)
        return;
    const map = tr.mapping;
    for (let i = 0; i < results.length; i += 1) {
        const { from, to } = results[i];
        tr.insertText(replaceTerm, from, to);
        if (i + 1 < results.length) {
            const { from, to } = results[i + 1];
            results[i + 1] = {
                from: map.map(from),
                to: map.map(to)
            };
        }
    }
    return tr;
};
export const SearchReplace = Extension.create({
    name: "searchreplace",
    addOptions() {
        return {
            searchResultClass: "search-result",
            onStartSearch: () => false,
            onEndSearch: () => false
        };
    },
    addStorage() {
        return {
            selectedIndex: 0,
            results: [],
            selectedText: undefined
        };
    },
    addCommands() {
        return {
            startSearch: () => ({ state, commands }) => {
                const term = !state.selection.empty
                    ? state.doc.textBetween(state.selection.$from.pos, state.selection.$to.pos)
                    : undefined;
                if (term)
                    commands.search(term);
                return this.options.onStartSearch(term);
            },
            endSearch: () => ({ state, dispatch }) => {
                state.tr.setMeta("isSearching", false);
                if (dispatch)
                    updateView(state, dispatch);
                return this.options.onEndSearch();
            },
            search: (term, options) => ({ state, dispatch }) => {
                state.tr.setMeta("isSearching", true);
                state.tr.setMeta("searchTerm", term);
                state.tr.setMeta("enableRegex", options === null || options === void 0 ? void 0 : options.enableRegex);
                state.tr.setMeta("matchCase", options === null || options === void 0 ? void 0 : options.matchCase);
                state.tr.setMeta("matchWholeWord", options === null || options === void 0 ? void 0 : options.matchWholeWord);
                if (dispatch)
                    updateView(state, dispatch);
                return true;
            },
            moveToNextResult: () => ({ state, dispatch }) => {
                const { selectedIndex, results } = this.storage;
                if (!results || results.length <= 0)
                    return false;
                let nextIndex = selectedIndex + 1;
                if (isNaN(nextIndex) || nextIndex >= results.length)
                    nextIndex = 0;
                const { tr } = state;
                const { from, to } = results[nextIndex];
                tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(from), tr.mapping.map(to)));
                scrollIntoView();
                this.storage.selectedIndex = nextIndex;
                tr.setMeta("selectedIndex", nextIndex);
                if (dispatch)
                    updateView(state, dispatch);
                return true;
            },
            moveToPreviousResult: () => ({ state, dispatch }) => {
                const { selectedIndex, results } = this.storage;
                if (!results || results.length <= 0)
                    return false;
                let prevIndex = selectedIndex - 1;
                if (isNaN(prevIndex) || prevIndex < 0)
                    prevIndex = results.length - 1;
                const { tr } = state;
                const { from, to } = results[prevIndex];
                tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(from), tr.mapping.map(to)));
                scrollIntoView();
                this.storage.selectedIndex = prevIndex;
                tr.setMeta("selectedIndex", prevIndex);
                if (dispatch)
                    updateView(state, dispatch);
                return true;
            },
            replace: (term) => ({ chain, tr, dispatch }) => {
                const { selectedIndex, results } = this.storage;
                if (!dispatch || !results || results.length <= 0)
                    return false;
                const index = selectedIndex === undefined ? 0 : selectedIndex;
                const { from, to } = results[index];
                tr.insertText(term, from, to);
                return chain().moveToNextResult().run();
            },
            replaceAll: (term) => ({ tr, dispatch }) => {
                if (!dispatch)
                    return false;
                const { results } = this.storage;
                if (!dispatch || !results || results.length <= 0)
                    return false;
                dispatch(replaceAll(term, results, tr));
                return true;
            }
        };
    },
    addKeyboardShortcuts() {
        return {
            "Mod-f": ({ editor }) => editor.commands.startSearch(),
            Escape: ({ editor }) => editor.commands.endSearch()
        };
    },
    addProseMirrorPlugins() {
        const key = new PluginKey("searchreplace");
        return [
            new Plugin({
                key,
                state: {
                    init() {
                        return {
                            results: DecorationSet.empty,
                            searchTerm: "",
                            isSearching: false,
                            enableRegex: false,
                            matchCase: false,
                            matchWholeWord: false,
                            selectedIndex: 0
                        };
                    },
                    apply: (tr, value) => {
                        var _a, _b, _c, _d, _e, _f;
                        const { docChanged } = tr;
                        const isSearching = (_a = tr.getMeta("isSearching")) !== null && _a !== void 0 ? _a : value.isSearching;
                        if (!isSearching)
                            return Object.assign(Object.assign({}, value), { isSearching: false, results: DecorationSet.empty });
                        const searchTerm = (_b = tr.getMeta("searchTerm")) !== null && _b !== void 0 ? _b : value.searchTerm;
                        const enableRegex = (_c = tr.getMeta("enableRegex")) !== null && _c !== void 0 ? _c : value.enableRegex;
                        const matchCase = (_d = tr.getMeta("matchCase")) !== null && _d !== void 0 ? _d : value.matchCase;
                        const matchWholeWord = (_e = tr.getMeta("matchWholeWord")) !== null && _e !== void 0 ? _e : value.matchWholeWord;
                        const selectedIndex = (_f = tr.getMeta("selectedIndex")) !== null && _f !== void 0 ? _f : value.selectedIndex;
                        const shouldResearch = docChanged ||
                            (isSearching !== value.isSearching && isSearching) ||
                            searchTerm !== value.searchTerm ||
                            matchCase !== value.matchCase ||
                            matchWholeWord !== value.matchWholeWord ||
                            enableRegex !== value.enableRegex;
                        if (selectedIndex !== value.selectedIndex &&
                            this.storage.results &&
                            !shouldResearch) {
                            return Object.assign(Object.assign({}, value), { selectedIndex, results: DecorationSet.create(tr.doc, resultsToDecorations(this.storage.results, this.options.searchResultClass, selectedIndex)) });
                        }
                        if (shouldResearch) {
                            const { searchResultClass } = this.options;
                            const searchRegex = searchTerm
                                ? regex(searchTerm, { enableRegex, matchCase, matchWholeWord })
                                : undefined;
                            const result = searchDocument(tr, searchResultClass, searchRegex, 0 // TODO: first index should be relative to cursor position
                            );
                            const { decorationSet, results, startIndex } = result;
                            this.storage.results = results;
                            this.storage.selectedIndex = startIndex;
                            return {
                                selectedIndex,
                                searchTerm,
                                isSearching,
                                results: decorationSet,
                                enableRegex,
                                matchCase,
                                matchWholeWord
                            };
                        }
                        return value;
                    }
                },
                props: {
                    decorations(state) {
                        return key.getState(state).results;
                    }
                }
            })
        ];
    }
});
function scrollIntoView() {
    setTimeout(() => {
        const domNode = document.querySelector(".search-result.selected");
        if (!(domNode instanceof HTMLElement))
            return;
        domNode.scrollIntoView({
            behavior: "instant",
            block: "center"
        });
    });
}
