import { LocationConflation } from "@rapideditor/location-conflation"
import type { Feature, FeatureCollection, MultiPolygon } from "geojson"
import { Utils } from "../../Utils"
import * as turf from "@turf/turf"
import { Mapping } from "../../Models/ThemeConfig/TagRenderingConfig"
import { Tag } from "../Tags/Tag"
import { TypedTranslation } from "../../UI/i18n/Translation"
import { RegexTag } from "../Tags/RegexTag"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { TagUtils } from "../Tags/TagUtils"
import Constants from "../../Models/Constants"

/**
 * Main name suggestion index file
 */
interface NSIFile {
    _meta: {
        version: string
        generated: string
        url: string
        hash: string
    }
    nsi: {
        [path: string]: NSIEntry
    }
}

/**
 * A collection of brands/operators/flagpoles/... with common properties
 * See https://github.com/osmlab/name-suggestion-index/wiki/Category-Files for an introduction and
 * https://github.com/osmlab/name-suggestion-index/blob/main/schema/categories.json for a full breakdown
 */
interface NSIEntry {
    properties: {
        path: string
        skipCollection?: boolean
        preserveTags?: string[]
        exclude: unknown
    }
    items: NSIItem[]
}

/**
 * Represents a single brand/operator/flagpole/...
 */
export interface NSIItem {
    readonly displayName: string
    readonly id: string
    locationSet: {
        include: string[]
        exclude: string[]
    }
    readonly tags: Readonly<Record<string, string>>
    fromTemplate?: boolean
    ext?: string
}

const supportedTypes = ["brand", "flag", "operator", "transit"] as const
export type NsiSupportedType = (typeof supportedTypes)[number]

export default class NameSuggestionIndex {
    public static readonly supportedTypes: ReadonlyArray<NsiSupportedType> = [
        "brand",
        "flag",
        "operator",
        "transit",
    ] as const
    protected readonly _serverLocation: string
    protected readonly nsiFile: Readonly<NSIFile>
    private readonly loco: LocationConflation // Some additional boundaries
    private static inited: NameSuggestionIndex = undefined
    private _supportedTypes: NsiSupportedType[]

    private constructor(
        serverLocation: string,
        nsiFile: Readonly<NSIFile>,
        features: Readonly<FeatureCollection>
    ) {
        this._serverLocation = serverLocation
        this.nsiFile = nsiFile
        this.loco = new LocationConflation(features)
        NameSuggestionIndex.inited = this
    }

    /**
     * Fetches the data files for a single country. Note that it contains _all_ entries having this brand, not for a single type of object
     * @param type
     * @param countries
     * @private
     */
    private async fetchFrequenciesFor(type: string, countries: string[]) {
        const server = this._serverLocation
        let stats = await Promise.all(
            countries.map((c) => {
                try {
                    return Utils.downloadJsonCached<Record<string, number>>(
                        `${server}/stats/${type}.${c.toUpperCase()}.json`,
                        24 * 60 * 60 * 1000
                    )
                } catch (e) {
                    console.error("Could not fetch " + type + " statistics due to", e)
                    return undefined
                }
            })
        )
        stats = Utils.NoNull(stats)
        if (stats.length === 1) {
            return stats[0]
        }
        if (stats.length === 0) {
            return {}
        }
        const merged = stats[0]
        for (let i = 1; i < stats.length; i++) {
            for (const countryCode in stats[i]) {
                merged[countryCode] = (merged[countryCode] ?? 0) + stats[i][countryCode]
            }
        }
        return merged
    }

    public async generateMappings(
        type: string,
        tags: Record<string, string>,
        country?: string[],
        location?: [number, number],
        options?: {
            /**
             * If set, sort by frequency instead of alphabetically
             */
            sortByFrequency: boolean
        }
    ): Promise<Mapping[]> {
        const mappings: (Mapping & { frequency: number })[] = []
        const frequencies =
            country !== undefined ? await this.fetchFrequenciesFor(type, country) : {}
        for (const key in tags) {
            if (key.startsWith("_")) {
                continue
            }
            const value = tags[key]
            const actualBrands = this.getSuggestionsForKV(
                type,
                key,
                value,
                country.join(";"),
                location
            )
            if (!actualBrands) {
                continue
            }
            for (const nsiItem of actualBrands) {
                const tags = nsiItem.tags
                const frequency = frequencies[nsiItem.displayName]
                const icon = this.getIconUrl(nsiItem)
                mappings.push({
                    if: new Tag(type, tags[type]),
                    addExtraTags: Object.keys(tags)
                        .filter((k) => k !== type)
                        .map((k) => new Tag(k, tags[k])),
                    then: new TypedTranslation<Record<string, never>>({ "*": nsiItem.displayName }),
                    hideInAnswer: false,
                    ifnot: undefined,
                    alsoShowIf: undefined,
                    icon,
                    iconClass: "medium",
                    // The 'frequency' is already for the country of the object we are working with
                    // As such, it should be "true" but this is not supported
                    priorityIf: frequency > 0 ? new RegexTag("id", /.*/) : undefined,
                    searchTerms: { "*": [nsiItem.displayName, nsiItem.id] },
                    frequency: frequency ?? -1,
                })
            }
        }
        if (options?.sortByFrequency) {
            mappings.sort((a, b) => b.frequency - a.frequency)
        }

        return mappings
    }

    public getIconUrl(nsiItem: NSIItem): string | undefined {
        const baseUrl = this._serverLocation
        if (!nsiItem.ext || baseUrl === null) {
            // No extension -> there is no logo
            return undefined
        }
        return baseUrl + "/logos/" + nsiItem.id + "." + nsiItem.ext
    }

    /**
     * Caching for the resolved sets, as they can take a while
     * @private
     */
    private static resolvedSets: Record<string, { type; location; id; feature }> = {}

    /**
     * Returns all suggestions for the given type (brand|operator) and main tag.
     * Can optionally be filtered by countries and location set
     *
     *
     * @param country: a string containing one or more country codes, separated by ";"
     * @param location: center point of the feature, should be [lon, lat]
     */
    public getSuggestionsForKV(
        type: string,
        key: string,
        value: string,
        country: string = undefined,
        location: [number, number] = undefined
    ): NSIItem[] {
        const path = `${type}s/${key}/${value}`
        const entry = this.nsiFile.nsi[path]
        const countries = country?.split(";") ?? []
        return entry?.items?.filter((i) => {
            if (i.locationSet.include.indexOf("001") >= 0) {
                // this brand is spread globally
                return true
            }

            if (country === undefined) {
                // IF the country is not set, we are probably in some international area, it isn't loaded yet,
                // or we are in a code path where we need everything (e.g. a script)
                // We just allow everything
                return true
            }

            if (i.locationSet.include.some((c) => countries.indexOf(c) >= 0)) {
                // We prefer the countries provided by lonlat2country, they are more precise and are loaded already anyway (cheap)
                // Country might contain multiple countries, separated by ';'
                return true
            }

            if (i.locationSet.exclude?.some((c) => countries.indexOf(c) >= 0)) {
                return false
            }

            if (location === undefined) {
                return true
            }

            const hasSpecial =
                i.locationSet.include?.some((i) => Array.isArray(i) || i.endsWith(".geojson")) ||
                i.locationSet.exclude?.some((i) => Array.isArray(i) || i.endsWith(".geojson"))
            if (!hasSpecial) {
                return false
            }
            const key = i.locationSet.include?.join(";") + "-" + i.locationSet.exclude?.join(";")
            const fromCache = NameSuggestionIndex.resolvedSets[key]
            const resolvedSet = fromCache ?? this.loco.resolveLocationSet(i.locationSet)
            if (!fromCache) {
                NameSuggestionIndex.resolvedSets[key] = resolvedSet
            }

            if (resolvedSet) {
                // We actually have a location set, so we can check if the feature is in it, by determining if our point is inside the MultiPolygon using @turf/boolean-point-in-polygon
                // This might occur for some extra boundaries, such as counties, ...
                const setFeature: Feature<MultiPolygon> = resolvedSet.feature
                return turf.booleanPointInPolygon(location, setFeature.geometry)
            }

            return false
        })
    }

    public static async singleton(): Promise<NameSuggestionIndex> {
        if (NameSuggestionIndex.inited) {
            return NameSuggestionIndex.inited
        }
        const endpoint = Constants.nsiLogosEndpoint ?? "./assets/data/nsi/"
        const [nsi, features] = await Promise.all(
            [endpoint + "nsi.min.json", endpoint + "featureCollection.min.json"].map((url) =>
                Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30)
            )
        )
        return new NameSuggestionIndex(endpoint, <any>nsi, <any>features)
    }

    public supportedTypes(): NsiSupportedType[] {
        if (this._supportedTypes) {
            return this._supportedTypes
        }
        const keys = Object.keys(this.nsiFile.nsi)
        const all = keys.map((k) => this.nsiFile.nsi[k].properties.path.split("/")[0])
        this._supportedTypes = Utils.Dedup(all).map((s) => {
            if (s.endsWith("s")) {
                s = s.substring(0, s.length - 1)
            }
            return <NsiSupportedType>s
        })
        return this._supportedTypes
    }

    public supportedTags(type: NsiSupportedType): Record<string, string[]> {
        const tags: Record<string, string[]> = {}
        const keys = Object.keys(this.nsiFile.nsi)
        for (const key of keys) {
            const nsiItem = this.nsiFile.nsi[key]
            const path = nsiItem.properties.path
            const [osmType, osmkey, osmvalue] = path.split("/")
            if (type !== osmType && type + "s" !== osmType) {
                continue
            }
            if (!tags[osmkey]) {
                tags[osmkey] = []
            }
            tags[osmkey].push(osmvalue)
        }
        return tags
    }

    public static async getNsiIndex(endPoint?: string): Promise<NameSuggestionIndex> {}

    /**
     * Returns a list of all brands/operators
     * @param type One of the supported types, e.g. "brand" or "operator"
     */
    public allPossible(type: NsiSupportedType): NSIItem[] {
        const options: NSIItem[] = []
        const tags = this.supportedTags(type)
        for (const osmKey in tags) {
            const values = tags[osmKey]
            for (const osmValue of values) {
                const suggestions = this.getSuggestionsForKV(type, osmKey, osmValue)
                if (!suggestions) {
                    console.warn("No suggestions found for", type, osmKey, osmValue)
                    continue
                }
                options.push(...suggestions)
            }
        }
        return options
    }

    /**
     *
     * @param country a string containing one or more country codes, separated by ";"
     * @param location center point of the feature, should be [lon, lat]
     */
    public getSuggestionsFor(
        type: string,
        tags: { key: string; value: string }[],
        country: string = undefined,
        location: [number, number] = undefined
    ): NSIItem[] {
        return tags.flatMap((tag) =>
            this.getSuggestionsForKV(type, tag.key, tag.value, country, location)
        )
    }

    public static async generateMappings(
        key: string,
        tags: Exclude<Record<string, string>, undefined | null>,
        country: string[],
        center: [number, number],
        options: {
            sortByFrequency: boolean
        }
    ): Promise<Mapping[]> {
        const nsi = await NameSuggestionIndex.singleton()
        return nsi.generateMappings(key, tags, country, center, options)
    }

    private static readonly brandPrefix = [
        "name",
        "alt_name",
        "operator",
        "brand",
        "official_name",
    ] as const

    /**
     * An NSI-item might have tags such as `name=X`, `alt_name=brand X`,  `brand=X`, `brand:wikidata`, `shop=Y`, `service:abc=yes`
     * Many of those tags are all added, but having only one of them is a good indication that it should match this item.
     *
     * This method is a heuristic which attempts to move all the brand-related tags into an `or` but still requiring the `shop` and other tags
     *
     * (More of an extension method on NSIItem)
     *
     * const item = {
     * displayName: "test",
     * id: "test",
     * locationSet: {include: ["BE"],exclude: []},
     * tags: {name:"XYZ", brand:"XYZ", alt_name: "ABC",official_name:"Association Brusselse Chou"}
     * }
     * NameSuggestionIndex.asFilterTags(item) // => {or: ["alt_name=ABC",  "brand=XYZ","name=XYZ","official_name=Association Brusselse Chou"]}
     */
    static asFilterTags(
        item: NSIItem
    ): string | { and: TagConfigJson[] } | { or: TagConfigJson[] } {
        const brandDetection: string[] = []
        const required: string[] = []
        const tags: Record<string, string> = item.tags
        for (const k in tags) {
            if (NameSuggestionIndex.brandPrefix.some((br) => k === br || k.startsWith(br + ":"))) {
                brandDetection.push(k + "=" + tags[k])
            } else {
                required.push(k + "=" + tags[k])
            }
        }
        return <TagConfigJson>TagUtils.optimzeJson({ and: [...required, { or: brandDetection }] })
    }
}

export class NamgeSuggestionWikidata {
    private readonly nsiWdFile: Readonly<
        Record<
            string,
            {
                logos: { wikidata?: string; facebook?: string }
            }
        >
    >

    constructor(
        nsiWdFile: Readonly<
            Record<
                string,
                {
                    logos: { wikidata?: string; facebook?: string }
                }
            >
        >
    ) {
        this.nsiWdFile = nsiWdFile
    }

    public isSvg(nsiItem: NSIItem, type: string): boolean | undefined {
        if (this.nsiWdFile === undefined) {
            throw (
                "nsiWdi file is not loaded, cannot determine if " + nsiItem.id + " has an SVG image"
            )
        }
        const logos = this.nsiWdFile[nsiItem?.tags?.[type + ":wikidata"]]?.logos
        if (!logos) {
            return undefined
        }
        if (logos.facebook) {
            return false
        }
        const url: string = logos.wikidata
        if (url.toLowerCase().endsWith(".svg")) {
            return true
        }
        return false
    }

    /**
     * Where can we find the URL on the world wide web?
     * Probably facebook! Don't use in the website, might expose people
     * @param nsiItem
     * @param type
     */
    private getIconExternalUrl(nsiItem: NSIItem, type: string): string {
        const logos = this.nsiWdFile[nsiItem.tags[type + ":wikidata"]]?.logos
        return logos?.facebook ?? logos?.wikidata
    }
}
