import Dispatcher from '@/stores/Dispatcher'
import { ErrorAction, InfoReceived, RouteRequestFailed, RouteRequestSuccess } from '@/actions/Actions'
import {
    ApiInfo,
    Bbox,
    ErrorResponse,
    GeocodingResult,
    Path,
    RawPath,
    RawResult,
    RoutingArgs,
    RoutingProfile,
    RoutingRequest,
    RoutingResult,
} from '@/api/graphhopper'
import { LineString } from 'geojson'
import { getTranslation, tr } from '@/translation/Translation'
import * as config from 'config'

interface ApiProfile {
    name: string
}

export default interface Api {
    info(): Promise<ApiInfo>

    infoWithDispatch(): void

    route(args: RoutingArgs): Promise<RoutingResult>

    routeWithDispatch(args: RoutingArgs): void

    geocode(query: string): Promise<GeocodingResult>
}

let api: Api | undefined

export function setApi(apiAddress: string, apiKey: string) {
    api = new ApiImpl(apiAddress, apiKey)
}

export function getApi() {
    if (!api) throw Error('Api must be initialized before it can be used. Use "setApi" when starting the app')
    return api
}

/**
 * Exporting this so that it can be tested directly. Don't know how to properly set this up in typescript, so that the
 * class could be tested but is not available for usage in the app. In Java one would make this package private I guess.
 */
export class ApiImpl implements Api {
    private readonly apiKey: string
    private readonly apiAddress: string

    constructor(apiAddress: string, apiKey: string) {
        this.apiAddress = apiAddress
        this.apiKey = apiKey
    }

    async info(): Promise<ApiInfo> {
        const response = await fetch(this.getURLWithKey('info').toString(), {
            headers: { Accept: 'application/json' },
        })

        if (response.ok) {
            const result = await response.json()
            return ApiImpl.convertToApiInfo(result)
        } else {
            throw new Error('Could not connect to the Service. Try to reload!')
        }
    }

    infoWithDispatch() {
        this.info()
            .then(result => Dispatcher.dispatch(new InfoReceived(result)))
            .catch(e => Dispatcher.dispatch(new ErrorAction(e.message)))
    }

    async geocode(query: string) {
        const url = this.getURLWithKey('geocode')
        url.searchParams.append('q', query)

        const response = await fetch(url.toString(), {
            headers: { Accept: 'application/json' },
        })

        if (response.ok) {
            return (await response.json()) as GeocodingResult
        } else {
            throw new Error('here could be your meaningful error message')
        }
    }

    async route(args: RoutingArgs): Promise<RoutingResult> {
        const completeRequest = ApiImpl.createRequest(args)

        const response = await fetch(this.getURLWithKey('route').toString(), {
            method: 'POST',
            mode: 'cors',
            body: JSON.stringify(completeRequest),
            headers: {
                Accept: 'application/json',
                'Content-Type': 'application/json',
            },
        })

        if (response.ok) {
            // parse from json
            const rawResult = (await response.json()) as RawResult

            // transform encoded points into decoded
            return {
                ...rawResult,
                paths: ApiImpl.decodeResult(rawResult, completeRequest.elevation),
            }
        } else if (response.status === 500) {
            // not always true, but most of the time :)
            throw new Error(tr('route_timed_out'))
        } else if (response.status === 400) {
            const errorResult = (await response.json()) as ErrorResponse
            let message = errorResult.message
            if (errorResult.hints && errorResult.hints.length > 0)
                message +=
                    (message ? message + ' and ' : '') +
                    (errorResult.hints as any[]).map(hint => hint.message).join(' and ')
            throw new Error(message)
        } else {
            throw new Error(tr('route_request_failed'))
        }
    }

    routeWithDispatch(args: RoutingArgs) {
        this.route(args)
            .then(result => Dispatcher.dispatch(new RouteRequestSuccess(args, result)))
            .catch(error => {
                console.warn('error when performing /route request: ', error)
                return Dispatcher.dispatch(new RouteRequestFailed(args, error.message))
            })
    }

    private getURLWithKey(endpoint: string) {
        const url = new URL(this.apiAddress + endpoint)
        url.searchParams.append('key', this.apiKey)
        return url
    }

    static createRequest(args: RoutingArgs): RoutingRequest {
        const request: RoutingRequest = {
            points: args.points,
            profile: args.profile,
            elevation: true,
            debug: false,
            instructions: true,
            locale: getTranslation().getLang(),
            optimize: 'false',
            points_encoded: true,
            snap_preventions: ['ferry'],
            details: ['road_class', 'road_environment', 'surface', 'max_speed', 'average_speed'],
            ...(config.extraProfiles ? (config.extraProfiles as any)[args.profile] : {}),
        }

        if (args.maxAlternativeRoutes > 1) {
            return {
                ...request,
                'alternative_route.max_paths': args.maxAlternativeRoutes,
                algorithm: 'alternative_route',
            }
        }
        return request
    }

    static convertToApiInfo(response: any): ApiInfo {
        let bbox = [0, 0, 0, 0] as Bbox
        let version = ''
        let import_date = ''
        const profiles: RoutingProfile[] = []

        for (const profileIndex in response.profiles as ApiProfile[]) {
            const profile: RoutingProfile = {
                name: response.profiles[profileIndex].name,
            }

            profiles.push(profile)
        }

        for (const property in response) {
            if (property === 'bbox') bbox = response[property]
            else if (property === 'version') version = response[property]
            else if (property === 'import_date') import_date = response[property]
        }

        return {
            profiles: profiles,
            elevation: response.elevation,
            bbox: bbox,
            version: version,
            import_date: import_date,
        }
    }

    private static decodeResult(result: RawResult, is3D: boolean) {
        return result.paths
            .map((path: RawPath) => {
                return {
                    ...path,
                    points: ApiImpl.decodePoints(path, is3D),
                    snapped_waypoints: ApiImpl.decodeWaypoints(path, is3D),
                } as Path
            })
            .map((path: Path) => {
                return {
                    ...path,
                    instructions: ApiImpl.setPointsOnInstructions(path),
                }
            })
    }

    private static decodePoints(path: RawPath, is3D: boolean) {
        if (path.points_encoded)
            return {
                type: 'LineString',
                coordinates: ApiImpl.decodePath(path.points as string, is3D),
            }
        else return path.points as LineString
    }

    private static decodeWaypoints(path: RawPath, is3D: boolean) {
        if (path.points_encoded)
            return {
                type: 'LineString',
                coordinates: ApiImpl.decodePath(path.snapped_waypoints as string, is3D),
            }
        else return path.snapped_waypoints as LineString
    }

    private static setPointsOnInstructions(path: Path) {
        if (path.instructions) {
            return path.instructions.map(instruction => {
                return {
                    ...instruction,
                    points: path.points.coordinates.slice(instruction.interval[0], instruction.interval[1] + 1),
                }
            })
        } else {
            return path.instructions
        }
    }

    private static decodePath(encoded: string, is3D: boolean): number[][] {
        const len = encoded.length
        let index = 0
        const array: number[][] = []
        let lat = 0
        let lng = 0
        let ele = 0

        while (index < len) {
            let b
            let shift = 0
            let result = 0
            do {
                b = encoded.charCodeAt(index++) - 63
                result |= (b & 0x1f) << shift
                shift += 5
            } while (b >= 0x20)
            const deltaLat = result & 1 ? ~(result >> 1) : result >> 1
            lat += deltaLat

            shift = 0
            result = 0
            do {
                b = encoded.charCodeAt(index++) - 63
                result |= (b & 0x1f) << shift
                shift += 5
            } while (b >= 0x20)
            const deltaLon = result & 1 ? ~(result >> 1) : result >> 1
            lng += deltaLon

            if (is3D) {
                // elevation
                shift = 0
                result = 0
                do {
                    b = encoded.charCodeAt(index++) - 63
                    result |= (b & 0x1f) << shift
                    shift += 5
                } while (b >= 0x20)
                const deltaEle = result & 1 ? ~(result >> 1) : result >> 1
                ele += deltaEle
                array.push([lng * 1e-5, lat * 1e-5, ele / 100])
            } else array.push([lng * 1e-5, lat * 1e-5])
        }
        // var end = new Date().getTime();
        // console.log("decoded " + len + " coordinates in " + ((end - start) / 1000) + "s");
        return array
    }
}