import { Store, UIEventSource } from "../UIEventSource"
import { OsmConnection } from "./OsmConnection"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { Utils } from "../../Utils"
import OSMAuthInstance = OSMAuth.osmAuth

export class OsmPreferences {
    /**
     * A 'cache' of all the preference stores
     * @private
     */
    private readonly preferences: Record<string, UIEventSource<string>> = {}

    private localStorageInited: Set<string> = new Set()
    /**
     * Contains all the keys as returned by the OSM-preferences.
     * This includes combined preferences, such as: pref, pref:0, pref:1
     * Used to clean up old preferences
     */
    private seenKeys: string[] = []

    /**
     * Contains a dictionary which has all preferences
     * @private
     */
    private readonly _allPreferences: UIEventSource<Record<string, string>> = new UIEventSource({})
    public readonly allPreferences: Store<Readonly<Record<string, string>>> = this._allPreferences
    private readonly _fakeUser: boolean
    private readonly auth: OSMAuthInstance
    private readonly osmConnection: OsmConnection

    constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) {
        this.auth = auth
        this._fakeUser = fakeUser
        this.osmConnection = osmConnection
        osmConnection.userDetails.addCallbackAndRunD(() => {
            this.loadBulkPreferences()
            return true
        })
    }

    /**
     * Sets a new preferenceValue in 'allPreferences'
     * @param key
     * @param value
     * @param deferping: the end user will ping '_allPreferences'
     * @private
     */
    private setPreferencesAll(key: string, value: string, deferping = false) {
        if (this._allPreferences.data[key] !== value) {
            this._allPreferences.data[key] = value
            if (!deferping) {
                this._allPreferences.ping()
            }
        }
    }

    private initPreference(
        key: string,
        value: string = undefined,
        deferPing = false
    ): UIEventSource<string> {
        const cached = this.preferences[key]
        if (cached !== undefined) {
            if (value !== undefined) {
                cached.set(value)
            }
            return cached
        }
        const pref = (this.preferences[key] = new UIEventSource(value, "preference: " + key))
        if (value) {
            this.setPreferencesAll(key, value, deferPing)
        }
        pref.addCallback((v) => {
            this.uploadKvSplit(key, v)
            this.setPreferencesAll(key, v, deferPing)
        })
        return pref
    }

    private async loadBulkPreferences() {
        const prefs = await this.getPreferencesDictDirectly()
        this.seenKeys = Object.keys(prefs)
        const merged = OsmPreferences.mergeDict(prefs)
        for (const key in merged) {
            this.initPreference(key, merged[key], true)
        }
        this._allPreferences.ping()
        if (this.osmConnection.isLoggedIn.data) {
            await this.cleanup()
        }
    }

    public getPreference(
        key: string,
        defaultValue: string = undefined,
        prefix?: string,
        saveLocally = true
    ) {
        return this.getPreferenceSeedFromlocal(key, defaultValue, {
            prefix,
            saveToLocalStorage: saveLocally,
        })
    }

    /**
     * Gets a OSM-preference.
     * The OSM-preference is cached in local storage and updated from the OSM.org as soon as those values come in.
     * THis means that values written before being logged in might be erased by the cloud settings
     */
    private getPreferenceSeedFromlocal(
        key: string,
        defaultValue: string = undefined,
        options?: {
            prefix?: string
            saveToLocalStorage?: true | boolean
        }
    ): UIEventSource<string> {
        if (options?.prefix) {
            key = options.prefix + key
        }
        key = key.replace(/[:/"' {}.%\\]/g, "")

        const localStorage = LocalStorageSource.get(key) // cached
        if (localStorage.data === "null" || localStorage.data === "undefined") {
            localStorage.set(undefined)
        }
        const pref: UIEventSource<string> = this.initPreference(
            key,
            localStorage.data ?? defaultValue
        ) // cached

        if (this.localStorageInited.has(key)) {
            return pref
        }

        if (options?.saveToLocalStorage ?? true) {
            pref.addCallback((v) => localStorage.set(v)) // Keep a local copy
        }
        this.localStorageInited.add(key)
        return pref
    }

    public ClearPreferences() {
        console.log("Starting to remove all preferences")
        this.removeAllWithPrefix("")
    }

    /**
     *
     * OsmPreferences.mergeDict({abc: "123", def: "123", "def:0": "456", "def:1":"789"}) // => {abc: "123", def: "123456789"}
     */
    private static mergeDict(dict: Record<string, string>): Record<string, string> {
        const keyParts: Record<string, Record<number, string>> = {}
        const endsWithNumber = /:[0-9]+$/
        for (const key of Object.keys(dict)) {
            if (key.match(/-combined-[0-9]*$/) || key.match(/-combined-length$/)) {
                continue
            }
            const nr = key.match(endsWithNumber)
            if (nr) {
                const i = Number(nr[0].substring(1))
                const k = key.substring(0, key.length - nr[0].length)
                let subparts = keyParts[k]
                if (!subparts) {
                    subparts = {}
                    keyParts[k] = subparts
                }
                subparts[i] = dict[key]
            } else {
                let subparts = keyParts[key]
                if (!subparts) {
                    subparts = keyParts[key] = {}
                }
                subparts[""] = dict[key]
            }
        }

        const newDict = {}

        for (const key in keyParts) {
            const subparts = keyParts[key]
            let i = 0
            let v = subparts[""] ?? ""
            while (subparts[i]) {
                v += subparts[i]
                i++
            }
            newDict[key] = v
        }

        return newDict
    }

    /**
     * Bulk-downloads all preferences, creates a simple record from all preferences.
     * This should still be merged!
     * @private
     */
    private async getPreferencesDictDirectly(): Promise<Record<string, string>> {
        if (!this.osmConnection.isLoggedIn.data) {
            return {}
        }
        return new Promise<Record<string, string>>((resolve, reject) => {
            this.auth.xhr(
                {
                    method: "GET",
                    path: "/api/0.6/user/preferences",
                },
                (error, value: XMLDocument) => {
                    if (error) {
                        console.log("Could not load preferences", error)
                        reject(error)
                        return
                    }
                    const prefs = value.getElementsByTagName("preference")
                    const dict: Record<string, string> = {}
                    for (let i = 0; i < prefs.length; i++) {
                        const pref = prefs[i]
                        const k = pref.getAttribute("k")
                        dict[k] = pref.getAttribute("v")
                    }
                    resolve(dict)
                }
            )
        })
    }

    private static readonly endsWithNumber = /:[0-9]+$/

    /**
     * Returns all keys matching `k:[number]`
     * Split separately for test
     *
     * const keys = ["abc", "def", "ghi", "ghi:0", "ghi:1"]
     * OsmPreferences.keysStartingWith(keys, "xyz") // => []
     * OsmPreferences.keysStartingWith(keys, "abc") // => ["abc"]
     * OsmPreferences.keysStartingWith(keys, "ghi") // => ["ghi", "ghi:0", "ghi:1"]
     *
     */
    private static keysStartingWith(allKeys: string[], key: string): string[] {
        const keys = allKeys.filter((k) => {
            if (k === key) {
                return true
            }
            if (!k.startsWith(key)) {
                return false
            }
            const match = k.match(OsmPreferences.endsWithNumber)
            if (!match) {
                return false
            }
            const matchLength = match[0].length
            if (key.length + matchLength !== k.length) {
                return false
            }
            return true
        })
        keys.sort()
        return keys
    }

    /**
     * Smart 'upload', which splits the value into `k`, `k:0`, `k:1` if needed.
     * If `v` is null, undefined, empty, "undefined" (literal string) or "null" (literal string), will delete `k` and `k:[number]`
     *
     */
    private async uploadKvSplit(k: string, v: string) {
        if (!this.osmConnection.isLoggedIn.data) {
            return
        }
        // _All_ keys are deleted first, to avoid pending parts
        const keysToDelete = OsmPreferences.keysStartingWith(this.seenKeys, k)
        await Promise.all(keysToDelete.map((k) => this.deleteKeyDirectly(k)))
        if (v === null || v === undefined || v === "" || v === "undefined" || v === "null") {
            return
        }

        await this.uploadKeyDirectly(k, v.slice(0, 255))
        v = v.slice(255)
        let i = 0
        while (v.length > 0) {
            await this.uploadKeyDirectly(`${k}:${i}`, v.slice(0, 255))
            v = v.slice(255)
            i++
        }
    }

    /**
     * Directly deletes this key
     * @param k
     * @private
     */
    private deleteKeyDirectly(k: string) {
        if (!this.osmConnection.isLoggedIn.data) {
            console.debug(`Not deleting preference ${k}: user not logged in`)
            return
        }

        if (this._fakeUser) {
            return
        }
        return new Promise<void>((resolve, reject) => {
            this.auth.xhr(
                {
                    method: "DELETE",
                    path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
                    headers: { "Content-Type": "text/plain" },
                },
                (error) => {
                    if (error) {
                        console.warn("Could not remove preference", error)
                        reject(error)
                        return
                    }
                    resolve()
                }
            )
        })
    }

    /**
     * Uploads the given k=v to the OSM-server
     * Deletes it if 'v' is undefined, null or empty
     */
    private async uploadKeyDirectly(k: string, v: string) {
        if (!this.osmConnection.isLoggedIn.data) {
            console.debug(`Not saving preference ${k}: user not logged in`)
            return
        }

        if (Utils.runningFromConsole) {
            return
        }

        if (this._fakeUser) {
            return
        }
        if (v === undefined || v === "" || v === null) {
            await this.deleteKeyDirectly(k)
            return
        }

        if (v.length > 255) {
            console.error("Preference too long, max 255 chars", { k, v })
            throw "Preference too long, at most 255 characters are supported"
        }

        try {
            return this.osmConnection.interact(
                "user/preferences/" + encodeURIComponent(k),
                "PUT",
                { "Content-Type": "text/plain" },
                v
            )
        } catch (e) {
            console.error("Could not upload preference due to", e)
        }
    }

    async removeAllWithPrefix(prefix: string) {
        const keys = this.seenKeys
        let somethingChanged = false
        for (const key of keys) {
            if (!key.startsWith(prefix)) {
                continue
            }
            console.log("Cleaning up preference", key)
            await this.deleteKeyDirectly(key)
            somethingChanged = true
        }
        return somethingChanged
    }

    private async cleanup() {
        const prefixesToClean = [
            "mapcomplete-mapcomplete-",
            "mapcomplete-places-history",
            "unofficial-theme-",
            "mapcompleteplaces",
            "mapcompletethemes",
        ] // TODO enable this one once the new system is in prod "mapcomplete-current-open-changeset-"]
        let somethingChanged = false
        for (const prefix of prefixesToClean) {
            const hasChange = await this.removeAllWithPrefix(prefix) // Don't inline - short-circuiting
            somethingChanged ||= hasChange
        }
        if (somethingChanged) {
            this._allPreferences.ping()
        }

        const themes = this.osmConnection.getAllOpenChangesetsPreferences()
        const now = new Date()

        for (const theme of themes.data) {
            const cs = this.osmConnection.getCurrentChangesetFor(theme)
            if (now.getTime() - cs.data.opened > 24 * 60 * 60 * 1000) {
                console.log(
                    "Clearing 'open changeset' for theme",
                    theme,
                    "; definitively expired by now"
                )
                cs.set(undefined)
            }
        }
    }
}
