import { Utils } from "../../Utils"
import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject"
import { NodeId, OsmId, RelationId, WayId } from "../../Models/OsmFeature"
import { Store, UIEventSource } from "../UIEventSource"
import { ChangeDescription } from "./Actions/ChangeDescription"

/**
 * The OSM-Object downloader downloads the latest version of the object, but applies 'pendingchanges' to them,
 * so that we always have a consistent view
 */
export default class OsmObjectDownloader {
    private readonly _changes?: {
        readonly pendingChanges: UIEventSource<ChangeDescription[]>
        readonly isUploading: Store<boolean>
    }
    private readonly backend: string
    private historyCache = new Map<string, UIEventSource<OsmObject[]>>()

    constructor(
        backend: string = "https://www.openstreetmap.org",
        changes?: {
            readonly pendingChanges: UIEventSource<ChangeDescription[]>
            readonly isUploading: Store<boolean>
        }
    ) {
        this._changes = changes
        if (!backend.endsWith("/")) {
            backend += "/"
        }
        if (!backend.startsWith("http")) {
            throw "Backend URL must begin with http"
        }
        this.backend = backend
    }

    async DownloadObjectAsync(id: NodeId, maxCacheAgeInSecs?: number): Promise<OsmNode | "deleted">

    async DownloadObjectAsync(id: WayId, maxCacheAgeInSecs?: number): Promise<OsmWay | "deleted">

    async DownloadObjectAsync(
        id: RelationId,
        maxCacheAgeInSecs?: number
    ): Promise<OsmRelation | undefined>

    async DownloadObjectAsync(id: OsmId, maxCacheAgeInSecs?: number): Promise<OsmObject | "deleted">

    async DownloadObjectAsync(
        id: string,
        maxCacheAgeInSecs?: number
    ): Promise<OsmObject | "deleted">

    async DownloadObjectAsync(id: string, maxCacheAgeInSecs?: number) {
        // Wait until uploading is done
        if (this._changes) {
            await this._changes.isUploading.AsPromise((o) => o === false)
        }

        const splitted = id.split("/")
        const type = splitted[0]
        const idN = Number(splitted[1])
        let obj: OsmObject | "deleted"
        if (idN < 0) {
            obj = this.constructObject(<"node" | "way" | "relation">type, idN)
        } else {
            obj = await this.RawDownloadObjectAsync(type, idN, maxCacheAgeInSecs)
        }
        if (obj === "deleted") {
            return obj
        }
        return await this.applyPendingChanges(obj)
    }

    public DownloadHistory(id: NodeId): UIEventSource<OsmNode[]>

    public DownloadHistory(id: WayId): UIEventSource<OsmWay[]>

    public DownloadHistory(id: RelationId): UIEventSource<OsmRelation[]>

    public DownloadHistory(id: OsmId): UIEventSource<OsmObject[]>

    public DownloadHistory(id: string): UIEventSource<OsmObject[]> {
        if (this.historyCache.has(id)) {
            return this.historyCache.get(id)
        }
        const splitted = id.split("/")
        const type = splitted[0]
        const idN = Number(splitted[1])
        const src = new UIEventSource<OsmObject[]>([])
        this.historyCache.set(id, src)
        Utils.downloadJsonCached(
            `${this.backend}api/0.6/${type}/${idN}/history`,
            10 * 60 * 1000
        ).then((data) => {
            const elements: any[] = data.elements
            const osmObjects: OsmObject[] = []
            for (const element of elements) {
                let osmObject: OsmObject = null
                element.nodes = []
                switch (type) {
                    case "node":
                        osmObject = new OsmNode(idN, element)
                        break
                    case "way":
                        osmObject = new OsmWay(idN, element)
                        break
                    case "relation":
                        osmObject = new OsmRelation(idN, element)
                        break
                }
                osmObject?.SaveExtraData(element, [])
                osmObjects.push(osmObject)
            }
            src.setData(osmObjects)
        })
        return src
    }

    /**
     * Downloads the ways that are using this node.
     * Beware: their geometry will be incomplete!
     */
    public async DownloadReferencingWays(id: string): Promise<OsmWay[]> {
        const data = await Utils.downloadJsonCached(`${this.backend}api/0.6/${id}/ways`, 60 * 1000)
        return data.elements.map((wayInfo) => new OsmWay(wayInfo.id, wayInfo))
    }

    /**
     * Downloads the relations that are using this feature.
     * Beware: their geometry will be incomplete!
     */
    public async DownloadReferencingRelations(id: string): Promise<OsmRelation[]> {
        const data = await Utils.downloadJsonCached(
            `${this.backend}api/0.6/${id}/relations`,
            60 * 1000
        )
        return data.elements.map((wayInfo) => {
            const rel = new OsmRelation(wayInfo.id, wayInfo)
            rel.SaveExtraData(wayInfo, undefined)
            return rel
        })
    }

    private applyNodeChange(object: OsmNode, change: { lat: number; lon: number }) {
        object.lat = change.lat
        object.lon = change.lon
    }

    private applyWayChange(object: OsmWay, change: { nodes: number[]; coordinates }) {
        object.nodes = change.nodes
        object.coordinates = change.coordinates.map(([lat, lon]) => [lon, lat])
    }

    private applyRelationChange(
        object: OsmRelation,
        change: { members: { type: "node" | "way" | "relation"; ref: number; role: string }[] }
    ) {
        object.members = change.members
    }

    private async applyPendingChanges(object: OsmObject): Promise<OsmObject | "deleted"> {
        if (!this._changes) {
            return object
        }
        const pendingChanges = this._changes.pendingChanges.data
        for (const pendingChange of pendingChanges) {
            if (object.id !== pendingChange.id || object.type !== pendingChange.type) {
                continue
            }
            if (pendingChange.doDelete) {
                return "deleted"
            }
            if (pendingChange.tags) {
                for (const { k, v } of pendingChange.tags) {
                    if (v === undefined) {
                        delete object.tags[k]
                    } else {
                        object.tags[k] = v
                    }
                }
            }

            if (pendingChange.changes) {
                switch (pendingChange.type) {
                    case "node":
                        this.applyNodeChange(<OsmNode>object, <any>pendingChange.changes)
                        break
                    case "way":
                        this.applyWayChange(<OsmWay>object, <any>pendingChange.changes)
                        break
                    case "relation":
                        this.applyRelationChange(<OsmRelation>object, <any>pendingChange.changes)
                        break
                }
            }
        }
        return object
    }

    /**
     * Creates an empty object of the specified type with the specified id.
     * We assume that the pending changes will be applied on them, filling in details such as coordinates, tags, ...
     */
    private constructObject(type: "node" | "way" | "relation", id: number): OsmObject {
        switch (type) {
            case "node":
                return new OsmNode(id)
            case "way":
                return new OsmWay(id)
            case "relation":
                return new OsmRelation(id)
        }
    }

    private async RawDownloadObjectAsync(
        type: string,
        idN: number,
        maxCacheAgeInSecs?: number
    ): Promise<OsmObject | "deleted"> {
        const full = type !== "node" ? "/full" : ""
        const url = `${this.backend}api/0.6/${type}/${idN}${full}`
        const rawData = await Utils.downloadJsonCachedAdvanced(
            url,
            (maxCacheAgeInSecs ?? 10) * 1000
        )
        if (rawData["error"] !== undefined && rawData["statuscode"] === 410) {
            return "deleted"
        }
        // A full query might contain more then just the requested object (e.g. nodes that are part of a way, where we only want the way)
        const parsed = OsmObject.ParseObjects(rawData["content"].elements)
        // Lets fetch the object we need
        for (const osmObject of parsed) {
            if (osmObject.type !== type) {
                continue
            }
            if (osmObject.id !== idN) {
                continue
            }
            // Found the one!
            return osmObject
        }
        throw "PANIC: requested object is not part of the response"
    }
}