import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"

export class MangroveIdentity {
    private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>(
        undefined
    )
    /**
     * Same as the one in the user settings
     */
    public readonly mangroveIdentity: UIEventSource<string>
    private readonly key_id: UIEventSource<string> = new UIEventSource<string>(undefined)
    private readonly _mangroveIdentityCreationDate: UIEventSource<string>

    constructor(
        mangroveIdentity: UIEventSource<string>,
        mangroveIdentityCreationDate: UIEventSource<string>
    ) {
        this.mangroveIdentity = mangroveIdentity
        this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
        mangroveIdentity.addCallbackAndRunD(async (data) => {
            if (data === "") {
                return
            }
            await this.setKeypair(data)
        })
    }

    private async setKeypair(data: string) {
        console.debug("Setting keypair from", data)
        const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))
        this.keypair.setData(keypair)
        const pem = await MangroveReviews.publicToPem(keypair.publicKey)
        this.key_id.setData(pem)
    }

    /**
     * Creates an identity if none exists already.
     * Is written into the UIEventsource, which was passed into the constructor
     * @constructor
     */
    private async CreateIdentity(): Promise<void> {
        const keypair = await MangroveReviews.generateKeypair()
        const jwk = await MangroveReviews.keypairToJwk(keypair)
        if ((this.mangroveIdentity.data ?? "") !== "") {
            // Identity has been loaded via osmPreferences by now - we don't overwrite
            return
        }
        this.keypair.setData(keypair)
        const pem = await MangroveReviews.publicToPem(keypair.publicKey)
        this.key_id.setData(pem)
        this.mangroveIdentity.setData(JSON.stringify(jwk))
        this._mangroveIdentityCreationDate.setData(new Date().toISOString())
    }

    /**
     * Only called to create a review.
     */
    async getKeypair(): Promise<CryptoKeyPair> {
        if (this.keypair.data === undefined) {
            // We want to create a review, but it seems like no key has been setup at this moment
            // We create the key
            try {
                if (!Utils.runningFromConsole && (this.mangroveIdentity.data ?? "") === "") {
                    await this.CreateIdentity()
                }
            } catch (e) {
                console.error("Could not create identity: ", e)
            }
        }
        return this.keypair.data
    }

    getKeyId(): Store<string> {
        return this.key_id
    }

    private geoReviewsById: Store<(Review & { kid: string; signature: string })[]> = undefined

    /**
     * Subset of `getAllReviews()` which contains all reviews which use the `geo:` as URL
     */
    public getGeoReviews(): Store<(Review & { kid: string; signature: string })[] | undefined> {
        if (!this.geoReviewsById) {
            this.geoReviewsById = this.getAllReviews().mapD((reviews) =>
                reviews.filter((review) => {
                    try {
                        const subjectUrl = new URL(review.sub)
                        return subjectUrl.protocol === "geo:"
                    } catch (e) {
                        return false
                    }
                })
            )
        }
        return this.geoReviewsById
    }

    private allReviewsById: UIEventSource<(Review & { kid: string; signature: string })[]> =
        undefined

    /**
     * Gets all reviews that are made for the current identity.
     * The returned store will contain `undefined` if still loading
     */
    public getAllReviews(): Store<(Review & { kid: string; signature: string })[] | undefined> {
        if (this.allReviewsById !== undefined) {
            return this.allReviewsById
        }
        this.allReviewsById = new UIEventSource(undefined)
        this.key_id.map(async (pem) => {
            if (pem === undefined) {
                return []
            }
            const allReviews = await MangroveReviews.getReviews({
                kid: pem,
            })
            this.allReviewsById.setData(
                allReviews.reviews.map((r) => ({
                    ...r,
                    ...r.payload,
                }))
            )
        })
        return this.allReviewsById
    }

    addReview(review: Review & { kid; signature }) {
        this.allReviewsById?.setData(this.allReviewsById?.data?.concat([review]))
    }
}

/**
 * Tracks all reviews of a given feature, allows to create a new review (and inserts this into the list)
 *
 * This object will start fetching the reviews as soon as it is constructed
 */
export default class FeatureReviews {
    /**
     * See https://gitlab.com/open-reviews/mangrove/-/blob/master/servers/reviewer/src/review.rs#L269 and https://source.mapcomplete.org/MapComplete/MapComplete/issues/1775
     */
    public static readonly REVIEW_OPINION_MAX_LENGTH = 1000
    private static readonly _featureReviewsCache: Record<string, FeatureReviews> = {}
    public readonly subjectUri: Store<string>
    public readonly average: Store<number | null>
    private readonly _reviews: UIEventSource<
        (Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[]
    > = new UIEventSource([])
    public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
        this._reviews
    private readonly _lat: number
    private readonly _lon: number
    private readonly _uncertainty: number
    private readonly _name: Store<string>
    private readonly _identity: MangroveIdentity
    private readonly _testmode: Store<boolean>
    public readonly loadingAllowed: UIEventSource<boolean | null>
    private readonly _options: Readonly<{
        nameKey?: "name" | string
        fallbackName?: string
        uncertaintyRadius?: number
    }>

    private constructor(
        feature: Feature,
        tagsSource: UIEventSource<Record<string, string>>,
        mangroveIdentity: MangroveIdentity,
        options?: Readonly<{
            nameKey?: "name" | string
            fallbackName?: string
            uncertaintyRadius?: number
        }>,
        testmode?: Store<boolean>,
        loadingAllowed?: UIEventSource<boolean | null>
    ) {
        this.loadingAllowed = loadingAllowed
        const centerLonLat = GeoOperations.centerpointCoordinates(feature)
        ;[this._lon, this._lat] = centerLonLat
        this._identity = mangroveIdentity
        this._testmode = testmode ?? new ImmutableStore(false)
        const nameKey = options?.nameKey ?? "name"
        this._options = options
        if (options.uncertaintyRadius) {
            this._uncertainty = options.uncertaintyRadius
        } else if (feature.geometry.type === "Point") {
            this._uncertainty = options?.uncertaintyRadius ?? 10
        } else {
            let coordss: Position[][]
            if (feature.geometry.type === "LineString") {
                coordss = [feature.geometry.coordinates]
            } else if (
                feature.geometry.type === "MultiLineString" ||
                feature.geometry.type === "Polygon"
            ) {
                coordss = feature.geometry.coordinates
            } else if (feature.geometry.type === "MultiPolygon") {
                coordss = feature.geometry.coordinates[0]
            } else {
                throw "Invalid feature type: " + feature.geometry.type
            }
            let maxDistance = 0
            for (const coords of coordss) {
                for (const coord of coords) {
                    maxDistance = Math.max(
                        maxDistance,
                        GeoOperations.distanceBetween(centerLonLat, coord)
                    )
                }
            }

            this._uncertainty = maxDistance
        }
        this._name = tagsSource.map((tags) => {
            const defaultName = tags[nameKey]
            if (defaultName && defaultName !== "") {
                console.log("Using default name:", defaultName, "fallback:", options.fallbackName)
                return defaultName
            }
            return options?.fallbackName
        })

        this.subjectUri = this.ConstructSubjectUri()

        this.subjectUri.mapD(
            async (sub) => {
                const reviews = await MangroveReviews.getReviews({ sub })
                console.debug("Got reviews for", feature, reviews, sub)
                this.addReviews(reviews.reviews, this._name.data)
            },
            [this._name]
        )
        /* We also construct all subject queries _without_ encoding the name to work around a previous bug
         * See https://github.com/giggls/opencampsitemap/issues/30
         */
        this.ConstructSubjectUri(true).mapD(
            async (sub) => {
                if (!loadingAllowed.data) {
                    return
                }
                try {
                    const reviews = await MangroveReviews.getReviews({ sub })
                    console.debug("Got reviews (no-encode) for", feature, reviews, sub)
                    this.addReviews(reviews.reviews, this._name.data)
                } catch (e) {
                    console.log("Could not fetch reviews for partially incorrect query ", sub)
                }
            },
            [this._name, loadingAllowed]
        )
        this.average = this._reviews.map((reviews) => {
            if (!reviews) {
                return null
            }
            if (reviews.length === 0) {
                return null
            }
            let sum = 0
            let count = 0
            for (const review of reviews) {
                if (review.rating !== undefined) {
                    count++
                    sum += review.rating
                }
            }
            return Math.round(sum / count)
        })
    }

    /**
     * Construct a featureReviewsFor or fetches it from the cache
     *
     * @param feature The feature that we want reviews of. Various properties are used to link reviews to the feature, namely the centerpoint and size and optionally the name
     * @param tagsSource Dynamic tags of the feature
     * @param mangroveIdentity Identity with which new REviews will be mad
     * @param options If options.nameKey is given, this key will be used as subject to fetch reviews
     *
     * const f = {type:"Feature", properties: {id: "node/1234"}, geometry: {type:"Point", coordinates: [12,13]}}
     * const reviews = FeatureReviews.construct(f, new UIEventSource({}), undefined, {fallbackName: "toilets"}, undefined)
     * reviews.subjectUri.data // => "geo:13,12?u=10&q=toilets"
     *
     * const f = {type:"Feature", properties: {id: "node/1234"}, geometry: {type:"Point", coordinates: [12,13]}}
     * const reviews = FeatureReviews.construct(f, new UIEventSource({name: ""}), undefined, {fallbackName: "toilets"}, undefined)
     * reviews.subjectUri.data // => "geo:13,12?u=10&q=toilets"
     */
    public static construct(
        feature: Feature,
        tagsSource: UIEventSource<Record<string, string>>,
        mangroveIdentity: MangroveIdentity,
        options: {
            nameKey?: "name" | string
            fallbackName?: string
            uncertaintyRadius?: number
        },
        state?: SpecialVisualizationState
    ): FeatureReviews {
        const key =
            feature.properties.id +
            ";" +
            (options?.nameKey ?? "") +
            ";" +
            (options?.fallbackName ?? "")
        const cached = FeatureReviews._featureReviewsCache[key]
        if (cached !== undefined) {
            return cached
        }
        const themeIsSensitive = state?.theme?.enableMorePrivacy ?? false
        const settings =
            state?.osmConnection?.getPreference<"always" | "yes" | "ask" | "hidden">(
                "reviews-allowed"
            ) ?? new ImmutableStore("yes")
        const loadingAllowed = new UIEventSource(false)
        settings.addCallbackAndRun((s) => {
            if (s === "hidden") {
                loadingAllowed.set(null)
                return
            }
            if (s === "always") {
                loadingAllowed.set(true)
                return
            }
            if (themeIsSensitive || s === "ask") {
                loadingAllowed.set(false)
                return
            }
            loadingAllowed.set(true)
        })
        const featureReviews = new FeatureReviews(
            feature,
            tagsSource,
            mangroveIdentity,
            options,
            state?.featureSwitchIsTesting,
            loadingAllowed
        )
        FeatureReviews._featureReviewsCache[key] = featureReviews
        return featureReviews
    }

    /**
     * The given review is uploaded to mangrove.reviews and added to the list of known reviews
     */
    public async createReview(review: Omit<Review, "sub">): Promise<void> {
        if (
            review.opinion !== undefined &&
            review.opinion.length > FeatureReviews.REVIEW_OPINION_MAX_LENGTH
        ) {
            throw (
                "Opinion too long, should be at most " +
                FeatureReviews.REVIEW_OPINION_MAX_LENGTH +
                " characters long"
            )
        }
        const r: Review = {
            sub: this.subjectUri.data,
            ...review,
        }
        const keypair: CryptoKeyPair = await this._identity.getKeypair()
        const jwt = await MangroveReviews.signReview(keypair, r)
        const kid = await MangroveReviews.publicToPem(keypair.publicKey)
        if (!this._testmode.data) {
            await MangroveReviews.submitReview(jwt)
        } else {
            console.log("Testmode enabled - not uploading review")
            await Utils.waitFor(1000)
        }
        const reviewWithKid = {
            ...r,
            kid,
            signature: jwt,
            madeByLoggedInUser: new ImmutableStore(true),
        }
        this._reviews.data.push(reviewWithKid)
        this._reviews.ping()
        this._identity.addReview(reviewWithKid)
    }

    /**
     * Adds given reviews to the 'reviews'-UI-eventsource, if they match close enough.
     * We assume only geo-reviews are passed in (as they should be queried using the 'geo'-part)
     * @param reviews
     * @private
     */
    private addReviews(
        reviews: { payload: Review; kid: string; signature: string }[],
        expectedName: string
    ) {
        const alreadyKnown = new Set(this._reviews.data.map((r) => r.rating + " " + r.opinion))

        let hasNew = false
        for (const reviewData of reviews) {
            const review = reviewData.payload

            try {
                const url = new URL(review.sub)
                if (url.protocol !== "geo:") {
                    continue
                }
                const coordinate = <[number, number]>url.pathname.split(",").map((n) => Number(n))
                const distance = GeoOperations.distanceBetween([this._lat, this._lon], coordinate)
                if (distance > this._uncertainty) {
                    continue
                }
                const nameUrl = url.searchParams.get("q")
                const distanceName =
                    Utils.levenshteinDistance(nameUrl.toLowerCase(), expectedName.toLowerCase()) /
                    expectedName.length

                if (distanceName > 0.25) {
                    // Then name is wildly different
                    continue
                }
            } catch (e) {
                console.warn(e)
                // Not a valid URL, ignore this review
                continue
            }

            const key = review.rating + " " + review.opinion
            if (alreadyKnown.has(key)) {
                continue
            }
            this._reviews.data.push({
                ...review,
                kid: reviewData.kid,
                signature: reviewData.signature,
                madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
                    return reviewData.kid === user_key_id
                }),
            })
            hasNew = true
        }
        if (hasNew) {
            this._reviews.data.sort((a, b) => b.iat - a.iat) // Sort with most recent first

            this._reviews.ping()
        }
    }

    /**
     * Gets an URI which represents the item in a mangrove-compatible way
     *
     * See https://mangrove.reviews/standard#mangrove-core-uri-schemes
     */
    private ConstructSubjectUri(dontEncodeName: boolean = false): Store<string> {
        // https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
        // `u` stands for `uncertainty`, https://www.rfc-editor.org/rfc/rfc5870#section-3.4.3
        return this._name.map((name) => {
            let uri = `geo:${this._lat},${this._lon}?u=${Math.round(this._uncertainty)}`
            if (name) {
                uri += "&q=" + (dontEncodeName ? name : encodeURIComponent(name))
            } else if (this._uncertainty > 1000) {
                console.error(
                    "Not fetching reviews. Only got a point and a very big uncertainty range (" +
                        this._uncertainty +
                        "), so you'd probably only get garbage. Specify a name"
                )
                return undefined
            }
            return uri
        })
    }
}
