import React, { ComponentPropsWithoutRef, FC, useContext, useEffect, useLayoutEffect, useRef } from "react"
import { MathJaxBaseContext, MathJaxOverrideableProps } from "../MathJaxContext"

export interface MathJaxProps extends MathJaxOverrideableProps {
    inline?: boolean
    onInitTypeset?: () => void
    onTypeset?: () => void
    text?: string
    dynamic?: boolean
}

const typesettingFailed = (err: any) =>
    `Typesetting failed: ${typeof err.message !== "undefined" ? err.message : err.toString()}`

const MathJax: FC<MathJaxProps & ComponentPropsWithoutRef<"span">> = ({
    inline = false,
    hideUntilTypeset,
    onInitTypeset,
    onTypeset,
    text,
    dynamic,
    typesettingOptions,
    renderMode,
    children,
    ...rest
}) => {
    // in render mode "pre", this keeps track of the last value on text to determine when we need to run typesetting
    const lastChildren = useRef<string>("")

    /* the parent of all MathJax content, in render mode "pre" the content generated by MathJax is added to this node
    after rendering whereas in render mode "post", the content of this node is processed by MathJax after rendering */
    const ref = useRef<HTMLElement>(null)

    const mjPromise = useContext(MathJaxBaseContext)

    // allow context values to steer this component for some props if they are undefined
    const usedHideUntilTypeset = hideUntilTypeset ?? mjPromise?.hideUntilTypeset
    const usedRenderMode = renderMode ?? mjPromise?.renderMode
    const usedConversionOptions = typesettingOptions ?? mjPromise?.typesettingOptions
    const usedDynamic = dynamic === false ? false : (dynamic || process.env.NODE_ENV !== "production")

    // whether initial typesetting of this element has been done or not
    const initLoad = useRef(false)

    // mutex to signal when typesetting is ongoing (without it we may have race conditions)
    const typesetting = useRef(false)

    // handler for initial loading
    const checkInitLoad = () => {
        if(!initLoad.current) {
            if(usedHideUntilTypeset === "first" && ref.current !== null) {
                ref.current.style.visibility = "visible"
            }
            if(onInitTypeset) onInitTypeset()
            initLoad.current = true
        }
    }

    // callback for when typesetting is done
    const onTypesetDone = () => {
        if(usedHideUntilTypeset === "every" && usedDynamic && usedRenderMode === "post" && ref.current !== null) {
            ref.current.style.visibility = rest.style?.visibility ?? "visible"
        }
        checkInitLoad()
        if(onTypeset) onTypeset()
        typesetting.current = false
    }

    // validator for text input with renderMode = "pre"
    const validText = (inputText?: string) => typeof inputText === "string" && inputText.length > 0

    // guard which resets the visibility to hidden when hiding the content between every typesetting
    if(
        !typesetting.current &&
        ref.current !== null &&
        usedDynamic &&
        usedHideUntilTypeset === "every" &&
        usedRenderMode === "post"
    ) {
        ref.current.style.visibility = "hidden"
    }

    /**
     * Effect for typesetting, important that this does not trigger a new render and runs as seldom as possible (only
     * when needed). It is good that it is in an effect because then we are sure that the DOM to be is ready and
     * thus, we don't have to use a custom timeout to accommodate for this. Layout effects runs on the DOM to be before
     * the browser has a chance to paint. Thereby, we reduce the chance of ugly flashes of non-typeset content.
     *
     * Note: useLayoutEffect causes an ugly warning in the server console with SSR so we make sure to use useEffect if
     * we are in the backend instead. Neither of them run in the backend so no extra care needs to be taken of the
     * Promise.reject() passed from context (which happens on SSR) on server.
     */
    const effectToUse = typeof window !== "undefined" ? useLayoutEffect : useEffect
    effectToUse(() => {
        if(usedDynamic || !initLoad.current) {
            if(ref.current !== null) {
                if(mjPromise) {
                    if(usedRenderMode === "pre") {
                        if(!validText(text))
                            throw Error(
                                `Render mode 'pre' requires text prop to be set and non-empty, which was currently "${text}"`
                            )
                        if(!typesettingOptions || !typesettingOptions.fn)
                            throw Error(
                                "Render mode 'pre' requires 'typesettingOptions' prop with 'fn' property to be set on MathJax element or in the MathJaxContext"
                            )
                        if(mjPromise.version === 2)
                            throw Error(
                                "Render mode 'pre' only available with MathJax 3, and version 2 is currently in use"
                            )
                    }
                    if(usedRenderMode === "post" || text !== lastChildren.current) {
                        if(!typesetting.current) {
                            typesetting.current = true
                            if(mjPromise.version === 3) {
                                mjPromise.promise
                                    .then((mathJax) => {
                                        if(usedRenderMode === "pre") {
                                            const updateFn = (output: HTMLElement) => {
                                                lastChildren.current = text!
                                                mathJax.startup.document.clear()
                                                mathJax.startup.document.updateDocument()
                                                if(ref.current !== null) ref.current.innerHTML = output.outerHTML
                                                onTypesetDone()
                                            }
                                            if(typesettingOptions!.fn.endsWith("Promise"))
                                                mathJax.startup.promise
                                                    .then(() =>
                                                        mathJax[usedConversionOptions!.fn](text, {
                                                            ...(usedConversionOptions?.options || {}),
                                                            display: !inline
                                                        })
                                                    )
                                                    .then(updateFn)
                                                    .catch((err) => {
                                                        onTypesetDone()
                                                        throw Error(typesettingFailed(err))
                                                    })
                                            else
                                                mathJax.startup.promise
                                                    .then(() => {
                                                        const output = mathJax[usedConversionOptions!.fn](text, {
                                                            ...(usedConversionOptions?.options || {}),
                                                            display: !inline
                                                        })
                                                        updateFn(output)
                                                    })
                                                    .catch((err) => {
                                                        onTypesetDone()
                                                        throw Error(typesettingFailed(err))
                                                    })
                                        } else {
                                            // renderMode "post"
                                            mathJax.startup.promise
                                                .then(() => {
                                                    mathJax.typesetClear([ref.current])
                                                    return mathJax.typesetPromise([ref.current])
                                                })
                                                .then(onTypesetDone)
                                                .catch((err) => {
                                                    onTypesetDone()
                                                    throw Error(typesettingFailed(err))
                                                })
                                        }
                                    })
                                    .catch((err) => {
                                        onTypesetDone()
                                        throw Error(typesettingFailed(err))
                                    })
                            } else {
                                // version 2
                                mjPromise.promise
                                    .then((mathJax) => {
                                        mathJax.Hub.Queue(["Typeset", mathJax.Hub, ref.current])
                                        mathJax.Hub.Queue(onTypesetDone)
                                    })
                                    .catch((err) => {
                                        onTypesetDone()
                                        throw Error(typesettingFailed(err))
                                    })
                            }
                        }
                    }
                } else
                    throw Error(
                        "MathJax was not loaded, did you use the MathJax component outside of a MathJaxContext?"
                    )
            }
        }
    })

    return (
        <span
            {...rest}
            style={{
                display: inline ? "inline" : "block",
                ...rest.style,
                visibility: usedHideUntilTypeset ? "hidden" : rest.style?.visibility
            }}
            ref={ref}
        >
            {children}
        </span>
    )
}

export default MathJax