import { MapObjectScatter } from './scatter.js'
import { DeepZoomImage, DeepZoomInfo } from '../deepzoom/image.js'
import { MapData, DeepZoomMapData } from './mapdata.js'
import { Points } from '../../utils.js'
import { EventHandler } from './utils.js'
import Mercator from './projections/mercator.js'

/**
*   The GeoMap class displays a map, that it gets from MapData object.
*   It handles the current location on the map, the zoom factor, interactions and
*   the viewport, the area of the map, the user can see and navigate to.
*
*   You should not instantiate GeoMap on it's own. Use the implemented child classes
*   or derive a new one from it.
*
*   Note: The name Map is already in use. Therefore GeoMap was used instead.
*

*   @abstract
*	@class
* 	@see {@link maps.html}
*/

export class GeoMap {
    /**
     * Creates instance of GeoMap
     *
     * @constructor
     * @param {MapData}[mapdata={}] - The mapdata describes how the map has to be interpreted by the Map class. E.g. what are the boundaries of the map?
     * @param {object}[opts={}] - With the opts, the created MapObjectScatter can be adjusted.
     * @param {boolean}[opts.cover=false] - Enables covering behaviour of a map object. Normally maps should cover the whole app.
     */
    constructor(
        mapdata = {},
        {
            debug = true,
            cover = true,
            // Scatter Options
            alpha = 1,
            startScale = 1,
            minScale = 0,
            maxScale = Infinity,
            translatable = true,
            scalable = true,
            rotatable = false, // Many functionalities are not supported when rotating the map. Mainly the cover mechanism.
            viewport = mapdata.maxViewport,
            // Events
            onLoad = null,
            onTransform = null
        } = {}
    ) {
        this._id = GeoMap.counter++
        this.onLoad = new EventHandler('loaded', { listeners: onLoad })
        this.onTransform = new EventHandler('transform', { listeners: onTransform })

        this.alpha = alpha
        this.cover = cover
        this.debug = debug

        //TODO discuss if this is required here.
        // Those are just scatter options and the information
        // is redundant in the map class and the scatter.
        this.startScale = startScale
        this.minScale = minScale
        this.maxScale = maxScale
        this.rotatable = rotatable
        this.translatable = translatable
        this.scalable = scalable
        this.viewport = viewport

        this.mapdata = mapdata
        this.overlays = {}

        /**
         * Adjust the viewport depending on the mapdata clipping.
         */
        if (this.mapdata.clip) {
            const vp = this.viewport
            const cp = this.mapdata.clip
            let bounds = {
                min: {
                    x: vp.min.x > cp.min.x ? vp.min.x : cp.min.x,
                    y: vp.min.y > cp.min.y ? vp.min.y : cp.min.y
                },
                max: {
                    x: vp.max.x < cp.max.x ? vp.max.x : cp.max.x,
                    y: vp.max.y < cp.max.y ? vp.max.y : cp.max.y
                }
            }

            this.viewport = bounds
        }
    }

    flushHandlers() {
        // this.onLoaded
        this.onTransform.empty()
    }

    lock({ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false } = {}) {
        if (this.image && this.image.scatter) {
            this.image.scatter.translatable = rotatable
            this.image.scatter.movableX = translatable
            this.image.scatter.movableY = movableX
            this.image.scatter.rotatable = movableY
            this.image.scatter.scalable = scalable
        }

        // Issue #001: This causes the map to not be displayed at the correct position on
        // map change.
        // // Rotation does not yet work with the cover mechanism.
        // //this.rotatable = false
        // this.translatable = false
        // this.scalable = false
    }

    unlock({ rotatable = true, translatable = true, movableX = true, movableY = true, scalable = true } = {}) {
        if (this.image && this.image.scatter) {
            this.image.scatter.translatable = translatable
            this.image.scatter.movableX = movableX
            this.image.scatter.movableY = movableY
            this.image.scatter.rotatable = rotatable
            this.image.scatter.scalable = scalable
        }
        // Issue #001
        // // Rotation does not yet work with the cover mechanism.
        // //this.rotatable = true
        // this.translatable = true
        // this.scalable = true
    }

    unload() {
        if (this.image) {
            if (this.image.parent) {
                this.image.parent.removeChild(this.image)
            }
            this.image.scatter = null
        }
    }

    /**
     * Is called when the scatter object is transformed.
     *
     * @private
     * @param {ScatterEvent}[e] - Contains informations on how the element was transformed.
     */
    transformed(e) {
        this.onTransform.call(this, e)
    }

    /**
     * Wrapps the display object around a scatter object.
     *
     * @private
     * @param {DisplayObject} displayObject - Defines the display object that will be wrapped inside the scatter object.
     * @param {PIXI.Container} container - Defines the frame in which the map will be displayed.
     */
    load(image, renderer, frame = null, scatter = null) {
        if (this.debug) console.log('Load image: ', image, frame)

        this.image = image
        if (frame) this.setFrame(frame)

        let min = this.mapdata.toPixel(this.viewport.min)
        let max = this.mapdata.toPixel(this.viewport.max)
        let boundaries = {
            min: { x: min.x, y: max.y },
            max: { x: max.x, y: min.y }
        }

        let scatterOpts = Object.assign({
            cover: this.cover,
            scaleable: this.scaleable,
            translatable: this.translatable,
            rotatable: this.rotatable,
            debug: this.debug,
            startScale: this.startScale,
            minScale: this.minScale,
            maxScale: this.maxScale,
            boundaries,
            onTransform: this.transformed.bind(this)
        })

        this.scatter = new MapObjectScatter(image, renderer, scatterOpts)
        this.image.scatter = scatter == null ? this.scatter : scatter

        this.onLoad.call(this)

        // Object.assign(this.image, {
        //     set scatter (value)  {
        //         console.trace("Scatter set.")
        //         this._scatter = value
        //     },
        //     get scatter (){
        //         console.trace("Get Scatter.")
        //         return this._scatter
        //     }
        // })
    }

    /**
     * Creates an identical copy of the current map.
     */
    clone() {
        console.error('Method clone() is abstract. Overload it in subclass', this)
    }

    /**
     *
     * Moves the 'camera' to another location on the map.
     *
     * @abstract
     * @param {object} coordinates - Target coordinates in form {x: latitude, y: longitude}
     * @param {number} scale - Target scale.
     */
    moveTo(coordinates, zoom = null, { animate = false } = {}) {
        console.error(this, 'The method moveTo(coordinates, zoom, opts) has not been defined in child class.')
    }

    zoomTo(zoomFactor) {
        console.error('Method zoomTo() is abstract. Overload it in subclass', this, zoomFactor)
    }

    /**
     * Reloads the textures of the image, when it was unloaded and is required again.
     *
     * @memberof GeoMap
     */
    reloadImage() {
        console.error(`Call of abstract method reloadImage(). Overwrite in subclass.`, this)
    }

    /**
     * Unloads the textures of the image, when not in use.
     *
     * @memberof GeoMap
     */
    unloadImage() {
        console.error(`Call of abstract method unloadImage(). Overwrite in subclass.`, this)
    }

    /**
     * Transforms a point at pixel position {x,y}
     * to a coordinate with latitude and longitude.
     *
     *
     * @param {object} point - Point in form of {x: x_val, y: y_val}.
     * @returns {object} - Coordinates on the map in form of {x: latitude, y: longitude}.
     */
    coordinatesFromPoint(point) {
        let coords = this.mapdata.toCoordinates(this.toRelativePosition(point))
        return coords
    }

    /**
     * Transform coordinates in the map into pixel positions on the deep zoom image.
     *
     * @param {object} coords - Coordinates of a map position in form {x: latitude, y: longitude}.
     * @return - Returns a image position in form of {x: x_val, y: y_val}.
     */
    coordinatesToPoint(coordinates) {
        return this.toAbsolutePixelCoordinates(this.mapdata.toPixel(coordinates))
    }

    toRelativePosition(point) {
        let _point = new PIXI.Point()

        _point.x = point.x / this.width
        _point.y = point.y / this.height

        return _point
    }

    toAbsolutePixelCoordinates(point) {
        let _point = new PIXI.Point()
        _point.x = point.x * this.width
        _point.y = point.y * this.height

        return _point
    }

    get width() {
        return this.image.scatter.width / this.image.scatter.scale
    }

    get height() {
        return this.image.scatter.height / this.image.scatter.scale
    }

    get distance() {
        console.error('Overload get distance in subclass.')
    }

    /**
     * Returns a screen point for a coordinate.
     */
    positionInWindow(coordinates) {
        let pos = this.coordinatesToPoint(coordinates)

        let framePos = {
            x: this.image.position.x + pos.x * this.image.scale.x,
            y: this.image.position.y + pos.y * this.image.scale.y
        }

        return framePos
    }

    /**
     * Returns the coordinates for a screen point.
     */
    coordinatesInWindow(point) {
        let mapPos = {
            x: (point.x - this.image.position.x) / this.image.scale.x,
            y: (point.y - this.image.position.y) / this.image.scale.y
        }

        let coords = this.coordinatesFromPoint(mapPos)
        return coords
    }

    setFrame(frame) {
        if (this.debug) console.log('Set Frame: ', frame)
        this.frame = frame
    }

    /**
     * Frame is the display in which the map is shown.
     * Normally it's the app, but it can be another element,
     * for example when in a submap.
     */
    getFrame() {
        let frame = {
            x: 0,
            y: 0,
            width: 0,
            height: 0
        }

        if (this.frame) {
            //The app does not have an x and y attribute.
            //Therefore we need the additional check.
            frame = {
                x: this.frame.x ? this.frame.x : 0,
                y: this.frame.y ? this.frame.y : 0,
                width: this.frame.width,
                height: this.frame.height
            }
        } else if (this.image.parent) {
            let parent = this.image.parent

            for (let key of Object.keys(frame)) {
                frame[key] = parent[key]
            }
        }

        frame.localCenter = {
            x: frame.width / 2,
            y: frame.height / 2
        }

        frame.center = {
            x: frame.x + frame.localCenter.x,
            y: frame.y + frame.localCenter.y
        }

        return frame
    }

    /**
     * Retrieves all maps from a json object.
     *
     * @static
     * @param {object} json - The parsed map data object.
     * @returns {object} - Returns an object with the names as keys and the GeoMaps as value.
     * @memberof GeoMap
     */
    static allFromJson(json) {
        let error = { message: '' }
        let maps = {}
        if (GeoMap._validateJson(json, error)) {
            for (let [mapname, data] of Object.entries(json)) {
                maps[mapname] = GeoMap._createMap(data)
                maps[mapname].name = mapname
            }
        } else console.error('Could not validate JSON: ' + error.message)

        return maps
    }

    /**
     *Extracts a single map from a JSON map file.
     *
     * @static
     * @param {string} map - Name of the map.
     * @param {object} json - Parsed json object.
     * @returns {GeoMap} - Returns a geomap, if the map was in the data and valid. Otherwise false.
     * @memberof GeoMap
     */
    static mapFromJson(map, json) {
        if (json[map]) {
            const data = json[map]
            if (this._validJsonMap(data)) return GeoMap._createMap(data)
            else console.error('Map was not in a valid format.')
        } else console.error('Map was not in data.')

        return null
    }

    /**
     *
     */

    /**
     * Validates if the map data contains valid data
     * for creating the maps.
     *
     * @static
     * @param {object} json - The object containing multiple map data sets.
     * @param {error-object} error - An object that contains an parameter message: {message = ""}. This is faking a call by reference.
     * @returns {boolean} - True if all sets were valid. False otherwise.
     * @memberof GeoMap
     */
    static _validateJson(json, error) {
        let isValid = true

        if (Object.keys(json).length == 0) {
            isValid = false
            error.message += 'The provided JSON object did not contain any items.'
        }
        for (let [name, data] of Object.entries(json)) {
            if (!GeoMap._validJsonMap(data)) {
                error.message += `${name} was not valid. `
                isValid = false
            }
        }

        if (error.message != '') error.message = 'Maps could not be validated: ' + error.message

        return isValid
    }

    static changePathsInJson(json, path) {
        for (let key of Object.keys(json)) {
            json[key].icon = path + json[key].icon
            json[key].tiles.path = path + json[key].tiles.path
        }
    }

    /**
     *Validates of a single data set contains the valid data for creating a map.
     *
     * @static
     * @param {object} json - The object containing a single set of map data.
     * @returns {boolean} - True if valid, otherwise false.
     * @memberof GeoMap
     */
    static _validJsonMap(json) {
        let isJsonValid = true
        const required = ['projection', 'type', 'icon']
        for (const requirement of Object.values(required)) {
            if (!json.hasOwnProperty(requirement)) {
                isJsonValid = false
                break
            }
        }

        if (isJsonValid && json.type.toLowerCase() == 'deepzoom') {
            if (!json.hasOwnProperty('tiles')) isJsonValid = false
        }

        return isJsonValid
    }

    static _createMap(data) {
        switch (data.type.toLowerCase()) {
            case 'deepzoom':
                return GeoMap._createDeepZoomMap(data)
            default:
                console.error(`Datatype is invalid or not implemented yet: ${data.type}`)
        }
    }

    static _createDeepZoomMap(data) {
        const projection = GeoMap._getProjectionByName(data.projection)
        const tilesConfig = data.tiles
        const options = data.options

        const mapdata = new DeepZoomMapData(projection, tilesConfig)
        return new DeepZoomMap(mapdata, tilesConfig, options)
    }

    static _getProjectionByName(projection) {
        switch (projection.toLowerCase()) {
            case 'mercator':
                return new Mercator()
            default:
                console.error(`Projection is invalid or not implemented yet ${projection}.`)
                return null
        }
    }
}

GeoMap.counter = 0

/**
 *   The DeepZoomMap class extends the GeoMap to create
 *   maps as deepzoom images from maptiles.
 *
 *   @extends GeoMap
 *	@class
 * 	@see {@link maps.html}
 */

export class DeepZoomMap extends GeoMap {
    /**
     * @constructor
     * @param {object} tilesConfig - The tiles config object, that defines at what path and in which format the tiles are.
     * @param {MapData} mapdata - A MapData object, that contains informations of how the given map has to be interpreted.
     * @param {object} opts - Additional options to specify the behaviour of the deep zoom image.
     */
    constructor(mapdata, tilesConfig, opts = {}) {
        if (!tilesConfig.app) console.error('App was not set in the tilesConfig.')
        opts = Object.assign(
            {
                maxScale: Math.min(tilesConfig.width, tilesConfig.height) / tilesConfig.tileSize,
                minScale: mapdata.getMinScale,
                highResolution: true,
                debug: false
            },
            opts
        )
        super(mapdata, opts)

        this.tilesConfig = tilesConfig
        this._verifyMapdata()
    }

    _verifyMapdata() {
        if (!(this.mapdata instanceof MapData)) {
            console.error('Use the MapData object for creating maps!')
        } else {
            if (this.mapdata instanceof DeepZoomMapData) {
                if (!this.mapdata.app) console.error('No app was set on the mapdata!')
            } else {
                console.error('Use the DeepZoomMapData object.')
            }
        }
    }

    /**
     * Creates the DeepZoomImage.
     *
     * @private
     */
    load(container = null, scatter = null) {
        this.info = new DeepZoomInfo(this.tilesConfig)
        let image = new DeepZoomImage(this.info, {
            app: this.mapdata.app,
            alpha: this.alpha,
            debug: this.debug,
            world: scatter == null ? scatter : scatter.getWorldScatter()
        })

        super.load(image, container, scatter)

        if (this.debug) console.log('Loaded image: ', image, 'With options: ', this.info)
    }

    unloadImage() {
        if (this.image) {
            this.image.deactivate()
        }
    }

    reloadImage() {
        this.image.activate()
    }

    /**
     * Event function that is invoked by the deep zoom image, when it has been transformed.
     *
     * @private
     * @param {ScatterEvent} e
     */
    transformed(e) {
        this.image.transformed(e)
        super.transformed(e)
    }

    /**
     * Jumps to a certain point on the map and adjusts the size of the
     * map depending on the distance (zoom).
     *
     *
     * @param {*} coordinates
     * @param {*} [zoom=null]
     * @param {*} [{ animate = 0 }={}]
     * @memberof DeepZoomMap
     */
    moveTo(coordinates, zoom = null, { animate = 0 } = {}) {
        if (this.image.scatter == null) {
            return
        }
        //TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018
        if (zoom) {
            /**
             * Zoom's the view to a specified distance in DZI-Levels.
             * The input level is clipped to allowed levels.
             * */
            let targetScale = this.scaleForFloatingLevel(zoom)

            // Don't scale beyond boundaries.
            if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale)
            if (this.minScale) targetScale = Math.max(targetScale, this.minScale)

            this.image.scatter.requestScale(targetScale)
        }

        if (coordinates) {
            //Get pixel position of lat lang values
            let point = this.coordinatesToPoint(coordinates)

            let containerCenter
            if (this.frame) {
                containerCenter = this.getFrame().center
            } else {
                containerCenter = {
                    x: this.image.parent.width / 2,
                    y: this.image.parent.height / 2
                }
            }

            //Calculate the delta depending on image position and center the
            // desired position in the frame.
            let delta = {
                x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x,
                y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y
            }

            if (animate > 0) {
                let last = 0
                TweenMax.to({ x: 0, y: 0 }, animate, {
                    ease: Power0.easeNone,
                    x: delta.x,
                    y: delta.y,
                    onUpdateParams: ['{self}'],
                    onUpdate: tween => {
                        let step = tween.ratio - last
                        last = tween.ratio
                        this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 })
                    }
                })
            } else {
                this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 })
            }
        }
        // return this.image.scatter.position
    }

    scaleForFloatingLevel(level) {
        level = Math.max(0, level - this.image.info.baseLevel)
        return Math.pow(2, level) / this.image.resolution
    }

    floatingLevelForScale(scale) {
        let level = Math.log2(scale * this.image.resolution)
        let newLevel = this.image.info.baseLevel + Math.max(level, 0)
        return Math.min(newLevel, this.image.info.maxLoadableLevel)
    }

    get distance() {
        return this.floatingLevelForScale(this.scatter.scale)
    }

    clone(container, scatter = null) {
        const map = new DeepZoomMap(this.mapdata, this.tilesConfig, {
            alpha: this.alpha,
            cover: this.cover,
            debug: this.debug,
            startScale: this.startScale,
            minScale: this.minScale,
            maxScale: this.maxScale,
            rotatable: this.rotatable,
            translatable: this.translatable,
            scalable: this.scalable,
            viewport: this.viewport
        })

        return map
    }

    tint() {
        let color = DeepZoomMap.tintcolors[DeepZoomMap.tintcolor++ % DeepZoomMap.tintcolors.length]
        this.image.children[0].children.forEach(tiles => {
            tiles.children.forEach(tile => {
                tile.tint = color
            })
        })
    }

    //    /** Returns the tile layer level that corresponds to the given scale.
    //  * @param {number} scale - the scale factor
    //  **/
    // levelForScale(scale) {
    //     let level = Math.round(Math.log2(scale * this.resolution)) // Math.floor(Math.log2(event.scale))+1
    //     let newLevel = this.info.baseLevel + Math.max(level, 0)
    //     return Math.min(newLevel, this.info.maxLoadableLevel)
    // }

    // /**
    //  * Returns the scale factor that correspond to a given level.
    //  * @param {number} level - the requested level
    //  * @returns {number} - Returns the scale level for the given level.
    //  */
    // scaleForLevel(level){
    //     level = Math.max(0, level - this.info.baseLevel)
    //     return Math.pow(2, level) / this.resolution
    // }
}

DeepZoomMap.tintcolors = [0xff0000, 0xff00ff, 0xffff00, 0x00ff00, 0x00ffff, 0x0000ff]
DeepZoomMap.tintcolor = 0

/**
 *
 * ImageMap extends GeoMap to display simple images
 * as maps.
 */

export class ImageMap extends GeoMap {
    constructor(sprite, mapdata, opts = {}) {
        super(mapdata, opts)

        this.sprite = sprite

        this.baseZoomHeight = opts.baseZoomHeight ? opts.baseZoomHeight : this.sprite.texture.height
    }

    load(container = null, scatter = null) {
        super.load(this.sprite, container, scatter)
        this.image.alpha = this.alpha
        this.image.interactive = true
    }

    clone(container = null, scatter = null) {
        const map = new ImageMap(new PIXI.Sprite(this.sprite.texture), this.mapdata, {
            alpha: this.alpha,
            cover: this.cover,
            debug: this.debug,
            startScale: this.startScale,
            minScale: this.minScale,
            maxScale: this.maxScale,
            rotatable: this.rotatable,
            translatable: this.translatable,
            scalable: this.scalable,
            viewport: this.viewport
        })
        map.load(container, scatter)

        return map
    }

    /**
     *
     *
     * @param {*} coordinates
     * @param {*} [zoom=null]
     * @param {*} [{ animate = 0 }={}]
     * @memberof ImageMap
     */
    moveTo(coordinates, zoom = null, { animate = 0 } = {}) {

        if (this.image.scatter == null) {
            return
        }
        //TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018
        if (zoom) {
            /**
             * Zoom's the view to a specified distance in DZI-Levels.
             * The input level is clipped to allowed levels.
             * */

            let targetScale = zoom * this.zoomFactor

            // Don't scale beyond boundaries.
            if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale)

            if (this.minScale) targetScale = Math.max(targetScale, this.minScale)

            this.image.scatter.requestScale(targetScale)
        }

        //coordinates = null
        if (coordinates) {
            //Get pixel position of lat lang values
            let point = this.coordinatesToPoint(coordinates)

            let containerCenter
            if (this.frame) {
                containerCenter = this.getFrame().localCenter
            } else {
                containerCenter = {
                    x: this.image.parent.width / 2,
                    y: this.image.parent.height / 2
                }
            }

            //Calculate the delta depending on image position and center the
            // desired position in the frame.

            let delta = {
                x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x,
                y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y
            }

            if (animate > 0) {
                let last = 0
                TweenMax.to({ x: 0, y: 0 }, animate, {
                    ease: Power0.easeNone,
                    x: delta.x,
                    y: delta.y,
                    onUpdateParams: ['{self}'],
                    onUpdate: tween => {
                        let step = tween.ratio - last
                        last = tween.ratio
                        this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 })
                    }
                })
            } else {
                this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 })
            }
        }
    }

    get zoom() {
        return this.scatter.scale / this.zoomFactor
    }

    get zoomFactor() {
        let factor = this.baseZoomHeight / this.sprite.texture.height
        return factor
    }
}