const DITHERED_IMAGE_STYLE = `
.ditheredImageStyle {
    width: 100%;
    height: 100%;
    padding: 0;
    margin: 0;
    image-rendering: crisp-edges;
}
`

const workerPath = document.currentScript.src.replace("as-dithered-image.js", "ditherworker.js")

class ASDitheredImage extends HTMLElement {
    constructor() {
        super()

        this.original_image_ = undefined
        this.force_refresh_ = false
        this.crunchFactor_ = this.getAutoCrunchFactor()
        this.canvas_ = undefined
        this.context_ = undefined
        this.image_loading_ = false
        this.ignore_next_resize_ = false
        this.worker_ = new Worker(workerPath)
        this.cutoff_ = 0.5
        this.darkrgba_ = [0, 0, 0, 255]
        this.lightrgba_ = [255, 255, 255, 255]

        this.worker_.onmessage = ((e) => {
            const imageData = e.data.imageData
            this.context_.putImageData(imageData, 0, 0)
        }).bind(this)

        this.resizing_timeout_ = undefined

        this.last_draw_state_ = { width: 0, height: 0, crunchFactor: 0, imageSrc: "" }
    }

    connectedCallback() {
        if (!this.isConnected) {
            return
        }

        const shadowDOM = this.attachShadow({ mode: "open" })

        const style = document.createElement("style")
        style.innerHTML = DITHERED_IMAGE_STYLE
        shadowDOM.appendChild(style)

        this.canvas_ = document.createElement("canvas")
        this.canvas_.setAttribute("role", "image")
        this.canvas_.setAttribute("aria-label", this.getAttribute("alt"))
        this.canvas_.classList.add("ditheredImageStyle")
        shadowDOM.appendChild(this.canvas_)

        this.context_ = this.canvas_.getContext("2d", { willReadFrequently: true })

        const resizeObserver = new ResizeObserver(((entries) => {
            // browsers generated lots of resize events but we don't want to start refreshing until
            // the user has stopped resizing the page

            if (entries.length > 0) {
                if (entries[0].contentBoxSize) {

                    if (this.ignore_next_resize_ == true) {
                        this.ignore_next_resize_ = false
                        return
                    }
                    if (this.resizing_timeout_ != undefined) {
                        clearTimeout(this.resizing_timeout_)
                    }
                    this.resizing_timeout_ = setTimeout((() => {
                        this.resizing_timeout_ = undefined
                        this.force_refresh_ = true
                        this.requestUpdate()
                    }).bind(this), 200)
                }

            }
        }).bind(this))

        resizeObserver.observe(this.canvas_)

        // since we avoid drawing the image if the element of far offscreen we need to use
        // an IntersectionObserver to notify use when the element is likely to be displayed
        const intersectionObserver = new IntersectionObserver(((intersections) => {
            if (intersections.length > 0) {
                if (intersections[0].isIntersecting) {
                    this.force_refresh_ = true
                    this.requestUpdate()
                }
            }
        }).bind(this), { root: null, rootMargin: "1000px", threshold: [0] })
        intersectionObserver.observe(this)

        this.force_refresh_ = true
        this.requestUpdate()
    }

    static get observedAttributes() { return ["src", "crunch", "alt", "cutoff", "darkrgba", "lightrgba"] }


    attributeChangedCallback(name, oldValue, newValue) {
        if (oldValue === newValue) return

        if ((name === "src")) {
            this.force_refresh_ = true
            this.original_image_ = undefined
            this.requestUpdate()
        } else if (name === "crunch") {
            if (newValue === "auto") {
                this.crunchFactor_ = this.getAutoCrunchFactor()
            } else if (newValue === "pixel") {
                this.crunchFactor_ = 1.0 / this.getDevicePixelRatio()
            } else {
                this.crunchFactor_ = parseInt(newValue, 10)
                if (isNaN(this.crunchFactor_)) {
                    this.crunchFactor_ = this.getAutoCrunchFactor()
                }
            }
            this.force_refresh_ = true
            this.requestUpdate()
        } else if (name === "alt") {
            this.altText = newValue;
            if (this.canvas != undefined) {
                let currentAltText = this.canvas.getAttribute("aria-label")
                if (currentAltText != newValue) {
                    this.canvas.setAttribute("aria-label", newValue)
                }
            }
        } else if (name === "cutoff") {
            this.cutoff_ = parseFloat(newValue)
            if (isNaN(this.cutoff_)) {
                this.cutoff_ = 0.5
            }
            this.cutoff_ = Math.min(1.0, Math.max(0.0, this.cutoff_))
            this.force_refresh_ = true
            this.requestUpdate()
        } else if (name === "darkrgba") {
            // must be in the form "rgba(10, 10, 10, 255)"
            this.darkrgba_ = this.parseRGBA(newValue)
            this.force_refresh_ = true
            this.requestUpdate()
        }
        else if (name === "lightrgba") {
            this.lightrgba_ = this.parseRGBA(newValue)
            this.force_refresh_ = true
            this.requestUpdate()
        }

    }

    // The crunch factor defaults 1 css pixel to 1 dither pixel which I think looks best when the device pixel ratio is 1 or 2
    // If the pixel ratio is 3 or above (like on my iPhone) then even css pixels are too small to make dithering
    // look effective, so I double the pixels again
    getAutoCrunchFactor() {
        if (this.getDevicePixelRatio() < 3) {
            return 1
        } else {
            return 2
        }
    }

    getDevicePixelRatio() {
        // this should always be an integer for the dithering code to work
        return window.devicePixelRatio
    }

    isInOrNearViewport() {
        // this only handles vertical scrolling, could be extended later to handle horizontal
        // but it probably doesn't matter
        const margin = 1500
        const r = this.getBoundingClientRect()

        const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight)
        const above = r.bottom + margin < 0
        const below = r.top - margin > viewHeight

        return (!above && !below)
    }

    // all drawing is funneled through requestUpdate so that multiple calls are coalesced to prevent
    // processing the image multiple times for no good reason
    requestUpdate() {

        if (this.original_image_ != undefined) {
            if (this.isInOrNearViewport() == false) {
                return // suppress update, the intersection observer will call us back as the element scrolls into view
            }
        }

        window.requestAnimationFrame(((timestamp) => {
            if ((this.force_refresh_ == false)) {
                return
            }
            if (this.original_image_ == undefined) {
                this.loadImage()
                return
            }
            if (this.force_refresh_) {
                this.repaintImage()
            }

        }).bind(this))
    }

    loadImage() {
        if (this.image_loading_ == true) {
            return
        }
        this.image_loading_ = true
        const image = new Image()
        image.src = this.getAttribute("src")

        // image.onerror is old and (literally) busted - it does not file on decode errors (ie if the src does not point to a valid image)
        // The new way is promise based - possibly better
        image.decode().then((() => {
            this.original_image_ = image
            this.ignore_next_resize_ = true
            this.canvas_.style.aspectRatio = this.original_image_.width + "/" + this.original_image_.height
            this.force_refresh_ = true
            this.requestUpdate()
        }).bind(this))
            .catch(((decodeError) => {
                console.log("Error decoding image: ", decodeError)
                this.original_image_ = undefined
            }).bind(this))
            .finally((() => {
                this.image_loading_ = false
            }).bind(this))
    }

    parseRGBA(s) {
        var matches = s.match(/^rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\,\s*(\d+)\s*\)$/i);
        if (matches) {
            return [matches[1], matches[2], matches[3], matches[4]]
        }
        else {
            return [255, 255, 255, 255]
        }
    }

    repaintImage() {
        const rect = this.canvas_.getBoundingClientRect()
        let screenPixelsToBackingStorePixels = this.getDevicePixelRatio()
        let fractionalPart = screenPixelsToBackingStorePixels - Math.floor(screenPixelsToBackingStorePixels)

        // that's it! I am officially giving up on trying to account for all the weird pixelDeviceRatios that Chrome likes
        // to serve up at different zoom levels. I can understand nice fractions like 2.5 but 1.110004 and 0.89233 are just stupid
        // If the fractional part doesn't make sense then just ignore it. This will give incorrect results but they still look
        // pretty good if you don't look too closely.
        if (this.getAttribute("crunch") == "pixel") {
            this.crunchFactor_ = 1.0 / this.getDevicePixelRatio()
        }

        if ((1.0 / fractionalPart) > 3) {
            fractionalPart = 0
            screenPixelsToBackingStorePixels = Math.round(screenPixelsToBackingStorePixels)
        }
        if (fractionalPart != 0) {
            screenPixelsToBackingStorePixels = Math.round(screenPixelsToBackingStorePixels * Math.round(1.0 / fractionalPart))
        }

        const calculatedWidth = Math.round(rect.width * screenPixelsToBackingStorePixels)
        const calculatedHeight = Math.round(rect.height * screenPixelsToBackingStorePixels)
        let adjustedPixelSize = Math.round(screenPixelsToBackingStorePixels * this.crunchFactor_)

        // double check - we may have already painted this image
        if ((this.last_draw_state_.width == calculatedWidth) &&
            (this.last_draw_state_.height == calculatedHeight) &&
            (this.last_draw_state_.adjustedPixelSize == adjustedPixelSize) &&
            (this.last_draw_state_.imageSrc == this.original_image_.currentSrc) &&
            (this.last_draw_state_.cutoff == this.cutoff_) &&
            (this.last_draw_state_.darkrgba == this.darkrgba_) &&
            (this.last_draw_state_.lightrgba == this.lightrgba_)
        ) {
            return;  // nothing to do
        }

        this.canvas_.width = calculatedWidth
        this.canvas_.height = calculatedHeight

        this.last_draw_state_.width = this.canvas_.width
        this.last_draw_state_.height = this.canvas_.height
        this.last_draw_state_.adjustedPixelSize = adjustedPixelSize
        this.last_draw_state_.imageSrc = this.original_image_.currentSrc
        this.last_draw_state_.cutoff = this.cutoff_
        this.last_draw_state_.darkrgba = this.darkrgba_
        this.last_draw_state_.lightrgba = this.lightrgba_


        this.context_.imageSmoothingEnabled = true
        this.context_.drawImage(this.original_image_, 0, 0, this.canvas_.width / adjustedPixelSize, this.canvas_.height / adjustedPixelSize)
        const originalData = this.context_.getImageData(0, 0, this.canvas_.width / adjustedPixelSize, this.canvas_.height / adjustedPixelSize)
        this.context_.clearRect(0, 0, this.canvas_.width, this.canvas_.height)
        // TODO: look at transferring the data in a different datastructure to prevent copying
        // unfortunately Safari has poor support for createImageBitmap - using it with ImageData doesn't work
        const msg = {}
        msg.imageData = originalData
        msg.pixelSize = adjustedPixelSize
        msg.cutoff = this.cutoff_
        msg.blackRGBA = this.darkrgba_
        msg.whiteRGBA = this.lightrgba_
        this.worker_.postMessage(msg)

        this.force_refresh_ = false
    }
}

window.customElements.define('as-dithered-image', ASDitheredImage);
