import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl } from "maplibre-gl"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox"
import { ExportableMap, KeyNavigationEvent, MapProperties } from "../../Models/MapProperties"
import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "./MaplibreMap.svelte"
import * as htmltoimage from "html-to-image"
import Constants from "../../Models/Constants"
import { Protocol } from "pmtiles"
import { GeoOperations } from "../../Logic/GeoOperations"
import { Feature, LineString } from "geojson"
import RasterLayerHandler from "./RasterLayerHandler"

/**
 * The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
 */
export class MapLibreAdaptor implements MapProperties, ExportableMap {
    private static maplibre_control_handlers = [
        // "scrollZoom",
        // "boxZoom",
        // "doubleClickZoom",
        "dragRotate",
        "dragPan",
        "keyboard",
        "touchZoomRotate",
    ]
    private static maplibre_zoom_handlers = [
        "scrollZoom",
        "boxZoom",
        "doubleClickZoom",
        "touchZoomRotate",
    ]
    readonly location: UIEventSource<{ lon: number; lat: number }>
    private readonly isFlying = new UIEventSource(false)
    readonly zoom: UIEventSource<number>
    readonly bounds: UIEventSource<BBox>
    readonly rasterLayer: UIEventSource<RasterLayerPolygon | undefined>
    readonly maxbounds: UIEventSource<BBox | undefined>
    readonly allowMoving: UIEventSource<true | boolean | undefined>
    readonly allowRotating: UIEventSource<true | boolean | undefined>
    readonly allowZooming: UIEventSource<true | boolean | undefined>
    readonly lastClickLocation: Store<
        | undefined
        | {
              lon: number
              lat: number
              mode: "left" | "right" | "middle"
              /**
               * The nearest feature from a MapComplete layer
               */
              nearestFeature?: Feature
          }
    >
    readonly minzoom: UIEventSource<number>
    readonly maxzoom: UIEventSource<number>
    readonly rotation: UIEventSource<number>
    readonly pitch: UIEventSource<number>
    readonly useTerrain: Store<boolean>
    readonly showScale: UIEventSource<boolean>

    private static pmtilesInited = false
    /**
     * Functions that are called when one of those actions has happened
     * @private
     */
    private _onKeyNavigation: ((event: KeyNavigationEvent) => void | boolean)[] = []

    private readonly _maplibreMap: Store<MLMap>

    constructor(
        maplibreMap: Store<MLMap>,
        state?: Partial<MapProperties>,
        options?: {
            correctClick?: number
        }
    ) {
        if (!MapLibreAdaptor.pmtilesInited) {
            maplibregl.addProtocol("pmtiles", new Protocol().tile)
            MapLibreAdaptor.pmtilesInited = true
        }
        this._maplibreMap = maplibreMap

        this.location = state?.location ?? new UIEventSource(undefined)
        if (this.location.data) {
            // The MapLibre adaptor updates the element in the location and then pings them
            // Often, code setting this up doesn't expect the object they pass in to be changed, so we create a copy
            this.location.setData({ ...this.location.data })
        }
        this.zoom = state?.zoom ?? new UIEventSource(1)
        this.minzoom = state?.minzoom ?? new UIEventSource(0.5) // 0.5 is the maplibre minzoom
        this.maxzoom = state?.maxzoom ?? new UIEventSource(24)
        this.zoom.addCallbackAndRunD((z) => {
            if (z < this.minzoom.data) {
                this.zoom.setData(this.minzoom.data)
            }
            const max = Math.min(24, this.maxzoom.data ?? 24)
            if (z > max) {
                this.zoom.setData(max)
            }
        })
        this.maxbounds = state?.maxbounds ?? new UIEventSource(undefined)
        this.allowMoving = state?.allowMoving ?? new UIEventSource(true)
        this.allowRotating = state?.allowRotating ?? new UIEventSource<boolean>(true)
        this.allowZooming = state?.allowZooming ?? new UIEventSource(true)
        this.bounds = state?.bounds ?? new UIEventSource(undefined)
        this.rotation = state?.rotation ?? new UIEventSource<number>(0)
        this.pitch = state?.pitch ?? new UIEventSource<number>(0)
        this.useTerrain = state?.useTerrain ?? new ImmutableStore<boolean>(false)
        this.rasterLayer =
            state?.rasterLayer ?? new UIEventSource<RasterLayerPolygon | undefined>(undefined)
        this.showScale = state?.showScale ?? new UIEventSource<boolean>(false)

        const lastClickLocation = new UIEventSource<{
            lat: number
            lon: number
            mode: "left" | "right" | "middle"
            nearestFeature?: Feature
        }>(undefined)
        this.lastClickLocation = lastClickLocation

        new RasterLayerHandler(this._maplibreMap, this.rasterLayer)

        const clickmodes = ["left", "middle", "right"] as const

        function handleClick(e: maplibregl.MapMouseEvent, mode?: "left" | "right" | "middle") {
            if (e.originalEvent["consumed"]) {
                // Workaround, 'ShowPointLayer' sets this flag
                return
            }
            const lon = e.lngLat.lng
            const lat = e.lngLat.lat
            const mouseEvent: MouseEvent = e.originalEvent
            mode = mode ?? clickmodes[mouseEvent.button]
            let nearestFeature: Feature = undefined
            if (options?.correctClick && maplibreMap.data) {
                const map = maplibreMap.data
                const point = e.point
                const buffer = options?.correctClick
                const features = map
                    .queryRenderedFeatures([
                        [point.x - buffer, point.y - buffer],
                        [point.x + buffer, point.y + buffer],
                    ])
                    .filter((f) => f.source.startsWith("mapcomplete_"))
                if (features.length === 1) {
                    nearestFeature = features[0]
                } else {
                    let nearestD: number = undefined
                    for (const feature of features) {
                        let d: number // in meter
                        if (feature.geometry.type === "LineString") {
                            const way = <Feature<LineString>>feature
                            const lngLat: [number, number] = [e.lngLat.lng, e.lngLat.lat]
                            const p = GeoOperations.nearestPoint(way, lngLat)
                            console.log(">>>", p, way, lngLat)
                            if (!p) {
                                continue
                            }
                            d = p.properties.dist * 1000
                            if (nearestFeature === undefined || d < nearestD) {
                                nearestFeature = way
                                nearestD = d
                            }
                        }
                    }
                }
            }
            lastClickLocation.setData({ lon, lat, mode, nearestFeature })
        }

        const syncStores = () => {
            this.MoveMapToCurrentLoc(this.location.data)
            this.SetZoom(this.zoom.data)
            this.setMaxBounds(this.maxbounds.data)
            this.setAllowMoving(this.allowMoving.data)
            this.setAllowRotating(this.allowRotating.data)
            this.setAllowZooming(this.allowZooming.data)
            this.setMinzoom(this.minzoom.data)
            this.setMaxzoom(this.maxzoom.data)
            this.setBounds(this.bounds.data)
            this.SetRotation(this.rotation.data)
            this.setScale(this.showScale.data)
            this.setTerrain(this.useTerrain.data)
            this.updateStores(true)
        }

        maplibreMap.addCallbackAndRunD((map) => {
            map.on("load", () => {
                syncStores()
            })
            syncStores()

            map.on("movestart", () => {
                this.isFlying.setData(true)
            })
            map.on("moveend", () => {
                this.isFlying.setData(false)
                this.updateStores()
            })
            map.on("click", (e) => {
                handleClick(e)
            })

            // map.on("contextmenu", ...) only works on desktop, hence we listen to the container:

            map._container.addEventListener("contextmenu", (e) => {
                const lngLat = map.unproject([e.x, e.y])
                lastClickLocation.setData({ lon: lngLat.lng, lat: lngLat.lat, mode: "right" })
            })
            map.on("dblclick", (e) => {
                handleClick(e, "left")
            })

            map.on("rotateend", (_) => {
                this.updateStores()
            })
            map.on("pitchend", () => {
                this.updateStores()
            })
            map.getContainer().addEventListener("keydown", (event) => {
                let locked: "islocked" = undefined
                if (!this.allowMoving.data) {
                    locked = "islocked"
                }
                switch (event.key) {
                    case "ArrowUp":
                        this.pingKeycodeEvent(locked ?? "north")
                        break
                    case "ArrowRight":
                        this.pingKeycodeEvent(locked ?? "east")
                        break
                    case "ArrowDown":
                        this.pingKeycodeEvent(locked ?? "south")
                        break
                    case "ArrowLeft":
                        this.pingKeycodeEvent(locked ?? "west")
                        break
                    case "+":
                        this.pingKeycodeEvent("in")
                        break
                    case "=":
                        this.pingKeycodeEvent("in")
                        break
                    case "-":
                        this.pingKeycodeEvent("out")
                        break
                }
            })
        })

        this.location.addCallbackAndRunD((loc) => {
            this.MoveMapToCurrentLoc(loc)
        })
        this.zoom.addCallbackAndRunD((z) => this.SetZoom(z))
        this.maxbounds.addCallbackAndRun((bbox) => this.setMaxBounds(bbox))
        this.rotation.addCallbackAndRunD((bearing) => this.SetRotation(bearing))
        this.allowMoving.addCallbackAndRun((allowMoving) => {
            this.setAllowMoving(allowMoving)
            this.pingKeycodeEvent(allowMoving ? "unlocked" : "locked")
        })
        this.allowRotating.addCallbackAndRunD((allowRotating) =>
            this.setAllowRotating(allowRotating)
        )
        this.allowZooming.addCallbackAndRun((allowZooming) => this.setAllowZooming(allowZooming))
        this.bounds.addCallbackAndRunD((bounds) => this.setBounds(bounds))
        this.useTerrain?.addCallbackAndRun((useTerrain) => this.setTerrain(useTerrain))
        this.showScale?.addCallbackAndRun((showScale) => this.setScale(showScale))
    }

    /**
     * Convenience constructor
     */
    public static construct(): {
        map: Store<MLMap>
        ui: SvelteUIElement
        mapproperties: MapProperties
    } {
        const mlmap = new UIEventSource<MlMap>(undefined)
        return {
            map: mlmap,
            ui: new SvelteUIElement(MaplibreMap, {
                map: mlmap,
            }),
            mapproperties: new MapLibreAdaptor(mlmap),
        }
    }

    /**
     * Prepares an ELI-URL to be compatible with mapbox
     */

    private static async toBlob(canvas: HTMLCanvasElement): Promise<Blob> {
        return await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob)))
    }

    private static async createImage(url: string): Promise<HTMLImageElement> {
        return new Promise((resolve, reject) => {
            const img = new Image()
            img.decode = () => resolve(img) as any
            img.onload = () => resolve(img)
            img.onerror = reject
            img.crossOrigin = "anonymous"
            img.decoding = "async"
            img.src = url
        })
    }

    public onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean) {
        this._onKeyNavigation.push(f)
        return () => {
            this._onKeyNavigation.splice(this._onKeyNavigation.indexOf(f), 1)
        }
    }

    public async exportAsPng(
        rescaleIcons: number = 1,
        progress: UIEventSource<{ current: number; total: number }> = undefined
    ): Promise<Blob> {
        const map = this._maplibreMap.data
        if (!map) {
            return undefined
        }
        const drawOn = document.createElement("canvas", {})
        const ctx = drawOn.getContext("2d")
        // The width/height has been set in 'mm' on the parent element and converted to pixels by the browser
        const w = map.getContainer().getBoundingClientRect().width
        const h = map.getContainer().getBoundingClientRect().height

        const dpi = map.getPixelRatio()
        // The 'css'-size stays constant...
        drawOn.style.width = w + "px"
        drawOn.style.height = h + "px"

        // ...but the number of pixels is increased
        drawOn.width = Math.ceil(w * dpi)
        drawOn.height = Math.ceil(h * dpi)

        await this.exportBackgroundOnCanvas(ctx)
        await this.drawMarkers(ctx, rescaleIcons, progress)
        return await MapLibreAdaptor.toBlob(drawOn)
    }

    private pingKeycodeEvent(
        key: "north" | "east" | "south" | "west" | "in" | "out" | "islocked" | "locked" | "unlocked"
    ) {
        const event = {
            date: new Date(),
            key: key,
        }

        for (let i = 0; i < this._onKeyNavigation.length; i++) {
            const f = this._onKeyNavigation[i]
            const unregister = f(event)
            if (unregister === true) {
                this._onKeyNavigation.splice(i, 1)
                i--
            }
        }
    }

    /**
     * Exports the background map and lines to PNG.
     * Markers are _not_ rendered
     */
    private async exportBackgroundOnCanvas(ctx: CanvasRenderingContext2D): Promise<void> {
        const map = this._maplibreMap.data
        // We draw the maplibre-map onto the canvas. This does not export markers
        // Inspiration by https://github.com/mapbox/mapbox-gl-js/issues/2766

        // Total hack - see https://stackoverflow.com/questions/42483449/mapbox-gl-js-export-map-to-png-or-pdf
        const promise = new Promise<void>((resolve) => {
            map.once("render", () => {
                ctx.drawImage(map.getCanvas(), 0, 0)
                resolve()
            })
        })

        while (!map.isStyleLoaded()) {
            console.log("Waiting to fully load the style...")
            await Utils.waitFor(100)
        }
        map.triggerRepaint()
        await promise
    }

    private async drawElement(
        drawOn: CanvasRenderingContext2D,
        element: HTMLElement,
        rescaleIcons: number,
        pixelRatio: number
    ) {
        {
            const allimages = element.getElementsByTagName("img")
            for (const img of Array.from(allimages)) {
                let isLoaded: boolean = false
                while (!isLoaded) {
                    console.log(
                        "Waiting for image",
                        img.src,
                        "to load",
                        img.complete,
                        img.naturalWidth,
                        img
                    )
                    await Utils.waitFor(250)
                    isLoaded = img.complete && img.width > 0
                }
            }
        }

        const style = element.style.transform
        let x = element.getBoundingClientRect().x
        let y = element.getBoundingClientRect().y
        element.style.transform = ""
        const offset = style.match(/translate\(([-0-9]+)%, ?([-0-9]+)%\)/)

        const labels = <HTMLElement[]>Array.from(element.getElementsByClassName("marker-label"))
        const origLabelTransforms = labels.map((l) => l.style.transform)
        // We save the original width (`w`) and height (`h`) in order to restore them later on
        const w = element.style.width
        const h = Number(element.style.height)
        const targetW = Math.max(
            element.getBoundingClientRect().width * 4,
            ...labels.map((l) => l.getBoundingClientRect().width)
        )
        const targetH =
            element.getBoundingClientRect().height +
            Math.max(
                ...labels.map(
                    (l) =>
                        l.getBoundingClientRect().height *
                        2 /* A bit of buffer to catch eventual 'margin-top'*/
                )
            )

        // Force a wider view for icon badges
        element.style.width = targetW + "px"
        // Force more height to include labels
        element.style.height = targetH + "px"
        element.classList.add("w-full", "flex", "flex-col", "items-center")
        labels.forEach((l) => {
            l.style.transform = ""
        })
        await Utils.awaitAnimationFrame()
        const svgSource = await htmltoimage.toSvg(element)
        const img = await MapLibreAdaptor.createImage(svgSource)
        for (let i = 0; i < labels.length; i++) {
            labels[i].style.transform = origLabelTransforms[i]
        }
        element.style.width = "" + w
        element.style.height = "" + h

        if (offset && rescaleIcons !== 1) {
            const [_, __, relYStr] = offset
            const relY = Number(relYStr)
            y += img.height * (relY / 100)
        }

        x *= pixelRatio
        y *= pixelRatio

        try {
            const xdiff = (img.width * rescaleIcons) / 2
            drawOn.drawImage(img, x - xdiff, y, img.width * rescaleIcons, img.height * rescaleIcons)
        } catch (e) {
            console.log("Could not draw image because of", e)
        }
        element.classList.remove("w-full", "flex", "flex-col", "items-center")
    }

    /**
     * Draws the markers of the current map on the specified canvas.
     * The DPIfactor is used to calculate the correct position, whereas 'rescaleIcons' can be used to make the icons smaller
     */
    private async drawMarkers(
        drawOn: CanvasRenderingContext2D,
        rescaleIcons: number = 1,
        progress: UIEventSource<{ current: number; total: number }>
    ): Promise<void> {
        const map = this._maplibreMap.data
        if (!map) {
            console.error("There is no map to export from")
            return undefined
        }

        const container = map.getContainer()
        const pixelRatio = map.getPixelRatio()

        function isDisplayed(el: Element) {
            const r1 = el.getBoundingClientRect()
            const r2 = container.getBoundingClientRect()
            return !(
                r2.left > r1.right ||
                r2.right < r1.left ||
                r2.top > r1.bottom ||
                r2.bottom < r1.top
            )
        }

        const markers = Array.from(container.getElementsByClassName("marker"))
        for (let i = 0; i < markers.length; i++) {
            const marker = <HTMLElement>markers[i]
            const style = marker.style.transform

            if (isDisplayed(marker)) {
                await this.drawElement(drawOn, marker, rescaleIcons, pixelRatio)
            }

            if (progress) {
                progress.setData({ current: i, total: markers.length })
            }

            marker.style.transform = style
        }
    }

    private updateStores(isSetup: boolean = false): void {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        const { lng, lat } = map.getCenter()
        if (lng === 0 && lat === 0) {
            return
        }
        if (this.location.data === undefined) {
            this.location.setData({ lon: lng, lat })
        } else if (!isSetup) {
            const lon = map.getCenter().lng
            const lat = map.getCenter().lat
            this.location.setData({ lon, lat })
        }
        this.zoom.setData(Math.round(map.getZoom() * 10) / 10)
        const bounds = map.getBounds()
        const bbox = new BBox([
            [bounds.getEast(), bounds.getNorth()],
            [bounds.getWest(), bounds.getSouth()],
        ])
        if (this.bounds.data === undefined || !isSetup) {
            this.bounds.setData(bbox)
        }
        this.rotation.setData(map.getBearing())
        this.pitch.setData(map.getPitch())
    }

    private SetZoom(z: number): void {
        const map = this._maplibreMap.data
        if (!map || z === undefined) {
            return
        }
        if (this.isFlying.data) {
            return
        }
        if (Math.abs(map.getZoom() - z) > 0.01) {
            map.setZoom(z)
        }
    }

    private SetRotation(bearing: number): void {
        const map = this._maplibreMap.data
        if (!map || bearing === undefined) {
            return
        }
        map.rotateTo(bearing, { duration: 0 })
    }

    private MoveMapToCurrentLoc(loc: { lat: number; lon: number }): void {
        const map = this._maplibreMap.data
        if (!map || loc === undefined) {
            return
        }

        const center = map.getCenter()
        if (center.lng !== loc.lon || center.lat !== loc.lat) {
            if (isNaN(loc.lon) || isNaN(loc.lat)) {
                console.error("Got invalid lat or lon, not setting")
            } else {
                map.setCenter({ lng: loc.lon, lat: loc.lat })
            }
        }
    }

    public installCustomKeyboardHandler(viewportStore: UIEventSource<HTMLDivElement>) {
        viewportStore.mapD(
            (viewport) => {
                const map = this._maplibreMap.data
                if (!map) {
                    return
                }
                const oldKeyboard = map.keyboard
                const w = viewport.getBoundingClientRect().width
                if (w < 10) {
                    /// this is weird, but definitively wrong!
                    console.log("Got a very small bound", w, viewport)
                    // We try again later on
                    window.requestAnimationFrame(() => {
                        viewportStore.ping()
                    })
                    return
                }
                oldKeyboard._panStep = w
            },
            [this._maplibreMap]
        )
    }

    private setMaxBounds(bbox: undefined | BBox) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        if (bbox) {
            if (GeoOperations.surfaceAreaInSqMeters(bbox.asGeojsonCached()) > 1) {
                map?.setMaxBounds(bbox.toLngLat())
            }
        } else {
            map?.setMaxBounds(null)
        }
    }

    private setAllowRotating(allow: true | boolean | undefined) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        if (allow === false) {
            map.rotateTo(0, { duration: 0 })
            map.setPitch(0)
            map.dragRotate.disable()
            map.keyboard.disableRotation()
            map.touchZoomRotate.disableRotation()
        } else {
            map.dragRotate.enable()
            map.keyboard.enableRotation()
            map.touchZoomRotate.enableRotation()
        }
    }

    private setAllowMoving(allow: true | boolean | undefined) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        if (allow === false) {
            for (const id of MapLibreAdaptor.maplibre_control_handlers) {
                map[id].disable()
            }
        } else {
            for (const id of MapLibreAdaptor.maplibre_control_handlers) {
                map[id].enable()
            }
        }
        this.setAllowRotating(this.allowRotating.data)
    }

    private setMinzoom(minzoom: number) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        map.setMinZoom(minzoom)
    }

    private setMaxzoom(maxzoom: number) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        map.setMaxZoom(maxzoom)
    }

    private setAllowZooming(allow: true | boolean | undefined) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        if (allow === false) {
            for (const id of MapLibreAdaptor.maplibre_zoom_handlers) {
                map[id].disable()
            }
        } else {
            for (const id of MapLibreAdaptor.maplibre_zoom_handlers) {
                map[id].enable()
            }
        }
    }

    private setBounds(bounds: BBox) {
        const map = this._maplibreMap.data
        if (!map || bounds === undefined) {
            return
        }
        const oldBounds = map.getBounds()
        const e = 0.0000001
        const hasDiff =
            Math.abs(oldBounds.getWest() - bounds.getWest()) > e &&
            Math.abs(oldBounds.getEast() - bounds.getEast()) > e &&
            Math.abs(oldBounds.getNorth() - bounds.getNorth()) > e &&
            Math.abs(oldBounds.getSouth() - bounds.getSouth()) > e
        if (!hasDiff) {
            return
        }
        this.lockZoom()
        map.fitBounds(bounds.toLngLat())
    }

    /**
     * Should be called before making an animation.
     * First, 'isFlying' is set to true. This will disable the zoom control
     * Then, zoom is set to '1', which is very low. This will generally disable all layers, after which this function will return
     *
     * Then, a zoom/pan/... animation can be made; after which a 'moveEnd'-event will trigger the 'isFlying' to be set to false and the zoom to be set correctly
     */
    private lockZoom() {
        this.isFlying.setData(true)
        this.zoom.setData(1)
    }

    private async setTerrain(useTerrain: boolean) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        const id = "maptiler-terrain-data"
        if (useTerrain) {
            if (map.getTerrain()) {
                return
            }
            map.addSource(id, {
                type: "raster-dem",
                url:
                    "https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=" +
                    Constants.maptilerApiKey,
            })
            try {
                while (!map?.isStyleLoaded()) {
                    await Utils.waitFor(250)
                }
                map.setTerrain({
                    source: id,
                })
            } catch (e) {
                console.error(e)
            }
        }
    }

    private scaleControl: maplibregl.ScaleControl = undefined

    private setScale(showScale: boolean) {
        const map = this._maplibreMap.data
        if (!map) {
            return
        }
        if (!showScale) {
            if (this.scaleControl) {
                map.removeControl(this.scaleControl)
                this.scaleControl = undefined
            }
            return
        }
        if (this.scaleControl === undefined) {
            this.scaleControl = new ScaleControl({
                maxWidth: 100,
                unit: "metric",
            })
        }
        if (!map.hasControl(this.scaleControl)) {
            map.addControl(this.scaleControl, "bottom-right")
        }
    }

    public flyTo(lon: number, lat: number, zoom: number) {
        this.lockZoom()
        window.requestAnimationFrame(() => {
            this._maplibreMap.data?.flyTo({
                zoom,
                center: [lon, lat],
            })
        })
    }
}
