import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact";

type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
    value: string;
    onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
};

function getCaretOffset(el: HTMLElement): number {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) return 0;
    const range = sel.getRangeAt(0).cloneRange();
    range.selectNodeContents(el);
    range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
    return range.toString().length;
}

function setCaretOffset(el: HTMLElement, offset: number) {
    const sel = window.getSelection();
    if (!sel) return;
    const range = document.createRange();
    let remaining = offset;

    function traverse(node: Node): boolean {
        if (node.nodeType === Node.TEXT_NODE) {
            const len = node.textContent?.length ?? 0;
            if (remaining <= len) {
                range.setStart(node, remaining);
                range.collapse(true);
                return true;
            }
            remaining -= len;
        } else {
            for (const child of Array.from(node.childNodes)) {
                if (traverse(child)) return true;
            }
        }
        return false;
    }

    if (!traverse(el)) {
        range.selectNodeContents(el);
        range.collapse(false);
    }

    sel.removeAllRanges();
    sel.addRange(range);
}

export const ContentEditable = ({ value, onInput, ...props }: Props) => {
    const ref = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const el = ref.current;
        if (!el || el.innerHTML === value) return;

        const offset = document.activeElement === el ? getCaretOffset(el) : null;
        el.innerHTML = value;
        if (offset !== null) setCaretOffset(el, offset);
    }, [value]);

    const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
        if (e.key !== 'Enter') return;
        const prevTextContent = (e.target as HTMLDivElement).textContent;
        e.preventDefault();

        const sel = window.getSelection();
        if (!sel || sel.rangeCount === 0) return;

        const range = sel.getRangeAt(0);
        range.deleteContents();

        const newline = document.createTextNode('\n');
        range.insertNode(newline);
        range.setStartAfter(newline);
        range.collapse(true);

        const nextTextContent = (e.target as HTMLDivElement).textContent;

        // A trailing \n needs a following character to render in pre-line.
        // If nothing follows the inserted newline, add a sentinel \n so the
        // new line is visible, then place the caret before it.
        const atEnd = nextTextContent.startsWith(prevTextContent) && nextTextContent !== prevTextContent && nextTextContent.length === prevTextContent.length + 1 && nextTextContent.at(-1) === '\n';
        if (atEnd) {
            const sentinel = document.createTextNode('\n');
            range.insertNode(sentinel);
            range.setStartBefore(sentinel);
            range.collapse(true);
        }

        sel.removeAllRanges();
        sel.addRange(range);

        ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true }));
    };

    return (
        <div
            ref={ref}
            contentEditable
            onKeyDown={handleKeyDown}
            onInput={onInput}
            {...props}
        />
    );
};
