import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx';
import { ObservableReactComponent } from '../../ObservableReactComponent';
import { observer } from 'mobx-react';
import { OverlayView } from '../../OverlayView';
import { DocumentIconContainer } from '../../nodes/DocumentIcon';
import React, { FormEvent } from 'react';
import { FieldView, FieldViewProps } from '../../nodes/FieldView';
import { FieldType, ObjectField } from '../../../../fields/ObjectField';
import { Doc } from '../../../../fields/Doc';
import { DocumentView } from '../../nodes/DocumentView';
import DOMPurify from 'dompurify';

/**
 * The SchemaCellField renders text in schema cells while the user is editing, and updates the
 * contents of the field based on user input. It handles some cell-side logic for equations, such
 * as how equations are broken up within the text.
 *
 * The current implementation parses innerHTML to create spans based on the text in the cell.
 * A more robust/safer approach would directly add elements in the react structure, but this
 * has been challenging to implement.
 */

export interface SchemaCellFieldProps {
    contents: FieldType | undefined;
    fieldContents?: FieldViewProps;
    editing?: boolean;
    oneLine?: boolean;
    Document: Doc;
    fieldKey: string;
    // eslint-disable-next-line no-use-before-define
    refSelectModeInfo: { enabled: boolean; currEditing: SchemaCellField | undefined };
    highlightCells?: (text: string) => void;
    GetValue(): string | undefined;
    SetValue(value: string, shiftDown?: boolean, enterKey?: boolean): boolean;
    getCells: (text: string) => HTMLDivElement[] | [];
}

@observer
export class SchemaCellField extends ObservableReactComponent<SchemaCellFieldProps> {
    private _disposers: { [name: string]: IReactionDisposer } = {};
    private _inputref: HTMLDivElement | null = null;
    private _unrenderedContent: string = '';
    _overlayDisposer?: () => void;
    @observable _editing: boolean = false;
    @observable _displayedContent = '';
    @observable _inCellSelectMode: boolean = false;
    @observable _dependencyMessageShown: boolean = false;

    constructor(props: SchemaCellFieldProps) {
        super(props);
        makeObservable(this);
        setTimeout(() => {
            this._unrenderedContent = this._props.GetValue() ?? '';
            this.setContent(this._unrenderedContent);
        }); //must be moved to end of batch or else other docs aren't loaded, so render as d-1 in function
    }

    get docIndex(){return DocumentView.getDocViewIndex(this._props.Document);} // prettier-ignore

    get selfRefPattern() {
        return `d${this.docIndex}.${this._props.fieldKey}`;
    }

    @computed get lastCharBeforeCursor() {
        const pos = this.cursorPosition;
        const content = this._unrenderedContent;
        const text = this._unrenderedContent.substring(0, pos ?? content.length);
        for (let i = text.length - 1; i > 0; --i) {
            if (text.charCodeAt(i) !== 160 && text.charCodeAt(i) !== 32) {
                return text[i];
            }
        }
        return null;
    }

    @computed get refSelectConditionMet() {
        const char = this.lastCharBeforeCursor;
        return char === '+' || char === '*' || char === '/' || char === '%' || char === '=';
    }

    componentDidMount(): void {
        this._unrenderedContent = this._props.GetValue() ?? '';
        this.setContent(this._unrenderedContent, true);
        this._disposers.editing = reaction(
            () => this._editing,
            editing => {
                if (editing) {
                    this.setupRefSelect(this.refSelectConditionMet);
                } else {
                    this._overlayDisposer?.();
                    this._overlayDisposer = undefined;
                    this._props.highlightCells?.('');
                    this.setupRefSelect(false);
                }
            },
            { fireImmediately: true }
        );
        this._disposers.fieldUpdate = reaction(
            () => this._props.GetValue(),
            fieldVal => {
                this._unrenderedContent = fieldVal ?? '';
                this.finalizeEdit(false, false, false);
            }
        );
    }

    componentDidUpdate(prevProps: Readonly<SchemaCellFieldProps>) {
        super.componentDidUpdate(prevProps);
        if (this._editing && this._props.editing === false) {
            this.finalizeEdit(false, true, false);
        } else
            runInAction(() => {
                if (this._props.editing !== undefined) this._editing = this._props.editing;
            });
    }

    _unmounted = false;
    componentWillUnmount(): void {
        this._unmounted = true;
        this._overlayDisposer?.();
        Object.values(this._disposers).forEach(disposer => disposer?.());
        this.finalizeEdit(false, true, false);
    }

    generateSpan = (text: string, cell: HTMLDivElement | undefined) => {
        const selfRef = text === this.selfRefPattern;
        return `<span style="text-decoration: ${selfRef ? 'underline' : 'none'}; text-decoration-color: red; color: ${selfRef ? 'gray' : cell?.style.borderTop.replace('2px solid', '')}">${text}</span>`;
    };

    makeSpans = (content: string) => {
        let chunkedText = content;

        const pattern = /(this|d(\d+))\.(\w+)/g;
        const matches: string[] = [];
        let match: RegExpExecArray | null;

        const cells: Map<string, HTMLDivElement> = new Map();

        while ((match = pattern.exec(content)) !== null) {
            const cell = this._props.getCells(match[0]);
            if (cell.length) {
                matches.push(match[0]);
                cells.set(match[0], cell[0]);
            }
        }

        matches.forEach(m => {
            chunkedText = chunkedText.replace(m, this.generateSpan(m, cells.get(m)));
        });

        return chunkedText;
    };

    /**
     * Sets the rendered content of the cell to save user inputs.
     * @param content the content to set
     * @param restoreCursorPos whether the cursor should be set back to where it was rather than the 0th index; should usually be true
     */
    @action
    setContent = (content: string, restoreCursorPos?: boolean) => {
        const pos = this.cursorPosition;
        this._displayedContent = DOMPurify.sanitize(this.makeSpans(content));
        restoreCursorPos && setTimeout(() => this.setCursorPosition(pos));
    };

    //Called from schemaview when a cell is selected to add a reference to the equation
    /**
     * Inserts text at the given index.
     * @param text The text to append.
     * @param atPos he index at which to insert the text. If empty, defaults to end.
     */
    @action
    insertText = (text: string, atPos?: boolean) => {
        const content = this._unrenderedContent;
        const cursorPos = this.cursorPosition;
        const robustPos = cursorPos ?? content.length;
        const newText = atPos ? content.slice(0, robustPos) + text + content.slice(cursorPos ?? content.length) : this._unrenderedContent.concat(text);
        this.onChange(undefined, newText);
        setTimeout(() => this.setCursorPosition(robustPos + text.length));
    };

    @action
    setIsFocused = (value: boolean) => {
        const wasFocused = this._editing;
        this._editing = value;
        this._editing && setTimeout(() => this._inputref?.focus());
        return wasFocused !== this._editing;
    };

    /**
     * Gets the cursor's position index within the text being edited.
     */
    get cursorPosition() {
        const selection = window.getSelection();
        if (!selection || selection.rangeCount === 0 || !this._inputref) return null;

        const range = selection.getRangeAt(0);
        const adjRange = range.cloneRange();

        adjRange.selectNodeContents(this._inputref);
        adjRange.setEnd(range.startContainer, range.startOffset);

        return adjRange.toString().length;
    }

    setCursorPosition = (position: number | null) => {
        const selection = window.getSelection();
        if (!selection || position === null || !this._inputref) return;

        const range = document.createRange();
        range.setStart(this._inputref, 0);
        range.collapse(true);

        let currentPos = 0;
        const setRange = (nodes: NodeList) => {
            for (let i = 0; i < nodes.length; ++i) {
                const node = nodes[i];

                if (node.nodeType === Node.TEXT_NODE) {
                    if (!node.textContent) return;
                    const nextPos = currentPos + node.textContent.length;
                    if (position <= nextPos) {
                        range.setStart(node, position - currentPos);
                        range.collapse(true);
                        selection.removeAllRanges();
                        selection.addRange(range);
                        return true;
                    }
                    currentPos = nextPos;
                } else if (node.nodeType === Node.ELEMENT_NODE && setRange(node.childNodes)) return true;
            }
            return false;
        };

        setRange(this._inputref.childNodes);
    };

    //This function checks if a visual update (eg. coloring a cell reference) should be made. It's meant to
    //save on processing upkeep vs. constantly rerendering, but I think the savings are minimal for now
    shouldUpdate = (prevVal: string, currVal: string) => {
        if (this._props.getCells(currVal).length !== this._props.getCells(prevVal).length) return true;
    };

    onChange = (e: FormEvent<HTMLDivElement> | undefined, newText?: string) => {
        const prevVal = this._unrenderedContent;
        const targVal = newText ?? e!.currentTarget.innerText; // TODO: bang
        if (!(targVal.startsWith(':=') || targVal.startsWith('='))) {
            this._overlayDisposer?.();
            this._overlayDisposer = undefined;
        } else if (!this._overlayDisposer) {
            this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 });
        }
        this._unrenderedContent = targVal;
        this._props.highlightCells?.(targVal);
        if (this.shouldUpdate(prevVal, targVal)) this.setContent(targVal, true);
        this.setupRefSelect(this.refSelectConditionMet);
    };

    setupRefSelect = (enabled: boolean) => {
        const properties = this._props.refSelectModeInfo;
        properties.enabled = enabled;
        properties.currEditing = enabled ? this : undefined;
    };

    @action
    onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
        switch (e.key) {
            case 'Tab':
                e.stopPropagation();
                this.finalizeEdit(e.shiftKey, false, false);
                break;
            case 'Backspace':
                e.stopPropagation();
                break;
            case 'Enter':
                e.stopPropagation();
                !e.ctrlKey && this.finalizeEdit(e.shiftKey, false, true);
                break;
            case 'Escape':
                e.stopPropagation();
                this._editing = false;
                break;
            case 'ArrowUp':
            case 'ArrowDown':
            case 'ArrowLeft':
            case 'ArrowRight': // prettier-ignore
                e.stopPropagation();
                setTimeout(() => this.setupRefSelect(this.refSelectConditionMet));
                break;
            case ' ':
                {
                    e.stopPropagation();
                    const cursorPos = this.cursorPosition !== null ? this.cursorPosition + 1 : 0;
                    setTimeout(() => {
                        this.setContent(this._unrenderedContent);
                        setTimeout(() => this.setCursorPosition(cursorPos));
                    });
                }
                break;
            case 'Shift':
            case 'Alt':
            case 'Meta':
            case 'Control':
            case ':':
            default:
                break;
        }
    };

    @action
    onClick = (e?: React.MouseEvent) => {
        if (this._props.editing !== false) {
            e?.nativeEvent.stopPropagation();
            this._editing = true;
        }
    };

    @action
    finalizeEdit = (shiftDown: boolean, lostFocus: boolean, enterKey: boolean) => {
        if (this._unmounted) {
            return;
        }
        if (this._unrenderedContent.replace(this.selfRefPattern, '') !== this._unrenderedContent) {
            if (this._dependencyMessageShown) {
                this._dependencyMessageShown = false;
            } else alert(`Circular dependency detected. Please update the field at ${this.selfRefPattern}.`);
            this._dependencyMessageShown = true;
            return;
        }

        this.setContent(this._unrenderedContent);

        if (!this._props.SetValue(this._unrenderedContent, shiftDown, enterKey) && !lostFocus) {
            setTimeout(action(() => (this._editing = true)));
        }
        this._editing = false;
    };

    staticDisplay = () => {
        return <span className="editableView-static">{this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : ''}</span>;
    };

    renderEditor = () => {
        return (
            <div
                contentEditable
                className="schemaField-editing"
                ref={r => (this._inputref = r)}
                style={{ minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20 }}
                onBlur={() => (this._props.refSelectModeInfo.enabled ? setTimeout(() => this.setIsFocused(true), 1000) : this.finalizeEdit(false, true, false))}
                onInput={this.onChange}
                onKeyDown={this.onKeyDown}
                onPointerDown={e => {
                    e.stopPropagation();
                    setTimeout(() => this.setupRefSelect(this.refSelectConditionMet), 0);
                }} //timeout callback ensures that refSelectMode is properly set
                onClick={e => e.stopPropagation}
                onPointerUp={e => e.stopPropagation}
                onPointerMove={e => {
                    e.stopPropagation();
                    e.preventDefault();
                }}
                dangerouslySetInnerHTML={{ __html: this._displayedContent }}></div>
        );
    };

    onFocus = () => {
        if (this._inputref?.innerText.startsWith('=') || this._inputref?.innerText.startsWith(':=')) {
            this._overlayDisposer?.();
            this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 });
            this._props.highlightCells?.(this._unrenderedContent);
            this.setContent(this._unrenderedContent);
            setTimeout(() => this.setCursorPosition(this._unrenderedContent.length));
        }
    };

    onBlur = action(() => {
        this._editing = false;
    });

    render() {
        const gval = this._props.GetValue()?.replace(/\n/g, '\\r\\n');
        if (this._editing && gval !== undefined) {
            return (
                <div
                    className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`} //
                    onFocus={this.onFocus}
                    onBlur={this.onBlur}>
                    {this.renderEditor()}
                </div>
            );
        } else
            return this._props.contents instanceof ObjectField ? null : (
                <div
                    className={`editableView-container-editing${this._props.oneLine ? '-oneLine' : ''}`}
                    onFocus={this.onFocus}
                    onBlur={this.onBlur}
                    style={{
                        minHeight: '10px',
                        whiteSpace: this._props.oneLine ? 'nowrap' : 'pre-line',
                        width: '100%',
                    }}
                    onClick={this.onClick}>
                    {this.staticDisplay()}
                </div>
            );
    }
}
