import { BoundingBox } from '../Collision/BoundingBox';
import { Vector, vec } from '../Math/vector';
import { Logger } from '../Util/Log';
import { Entity } from '../EntityComponentSystem/Entity';
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
import { BodyComponent } from '../Collision/BodyComponent';
import { CollisionType } from '../Collision/CollisionType';
import { Shape } from '../Collision/Colliders/Shape';
import { GraphicsComponent, hasGraphicsTick, ParallaxComponent } from '../Graphics';
import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent';
import { ColliderComponent } from '../Collision/ColliderComponent';
import { DebugGraphicsComponent } from '../Graphics/DebugGraphicsComponent';
import { PostDrawEvent, PostUpdateEvent, PreDrawEvent, PreUpdateEvent } from '../Events';
import { EventEmitter } from '../EventEmitter';
import { CoordPlane } from '../Math/coord-plane';
import { clamp } from '../Math/util';
import { PointerComponent } from '../Input/PointerComponent';
export const TileMapEvents = {
    PreUpdate: 'preupdate',
    PostUpdate: 'postupdate',
    PreDraw: 'predraw',
    PostDraw: 'postdraw',
    PointerUp: 'pointerup',
    PointerDown: 'pointerdown',
    PointerMove: 'pointermove',
    PointerCancel: 'pointercancel'
};
/**
 * The TileMap provides a mechanism for doing flat 2D tiles rendered in a grid.
 *
 * TileMaps are useful for top down or side scrolling grid oriented games.
 */
export class TileMap extends Entity {
    flagCollidersDirty() {
        this._collidersDirty = true;
    }
    flagTilesDirty() {
        for (let i = 0; i < this.tiles.length; i++) {
            if (this.tiles[i]) {
                this.tiles[i].flagDirty();
            }
        }
    }
    get x() {
        var _a;
        return (_a = this.transform.pos.x) !== null && _a !== void 0 ? _a : 0;
    }
    set x(val) {
        var _a;
        if ((_a = this.transform) === null || _a === void 0 ? void 0 : _a.pos) {
            this.get(TransformComponent).pos = vec(val, this.y);
        }
    }
    get y() {
        var _a, _b;
        return (_b = (_a = this.transform) === null || _a === void 0 ? void 0 : _a.pos.y) !== null && _b !== void 0 ? _b : 0;
    }
    set y(val) {
        var _a;
        if ((_a = this.transform) === null || _a === void 0 ? void 0 : _a.pos) {
            this.transform.pos = vec(this.x, val);
        }
    }
    get z() {
        var _a;
        return (_a = this.transform.z) !== null && _a !== void 0 ? _a : 0;
    }
    set z(val) {
        if (this.transform) {
            this.transform.z = val;
        }
    }
    get rotation() {
        var _a, _b;
        return (_b = (_a = this.transform) === null || _a === void 0 ? void 0 : _a.rotation) !== null && _b !== void 0 ? _b : 0;
    }
    set rotation(val) {
        if (this.transform) {
            this.transform.rotation = val;
        }
    }
    get scale() {
        var _a, _b;
        return (_b = (_a = this.transform) === null || _a === void 0 ? void 0 : _a.scale) !== null && _b !== void 0 ? _b : Vector.One;
    }
    set scale(val) {
        var _a;
        if ((_a = this.transform) === null || _a === void 0 ? void 0 : _a.scale) {
            this.transform.scale = val;
        }
    }
    get pos() {
        return this.transform.pos;
    }
    set pos(val) {
        this.transform.pos = val;
    }
    get vel() {
        return this._motion.vel;
    }
    set vel(val) {
        this._motion.vel = val;
    }
    emit(eventName, event) {
        this.events.emit(eventName, event);
    }
    on(eventName, handler) {
        return this.events.on(eventName, handler);
    }
    once(eventName, handler) {
        return this.events.once(eventName, handler);
    }
    off(eventName, handler) {
        this.events.off(eventName, handler);
    }
    /**
     * @param options
     */
    constructor(options) {
        var _a, _b, _c;
        super([], options.name);
        this.events = new EventEmitter();
        this._token = 0;
        this.logger = Logger.getInstance();
        this.tiles = [];
        this._rows = [];
        this._cols = [];
        this.renderFromTopOfGraphic = false;
        this.meshingLookBehind = 10;
        this._collidersDirty = true;
        this._forwardPointerEventToTile = (eventType) => (evt) => {
            const tile = this.getTileByPoint(evt.worldPos);
            if (tile) {
                tile.events.emit(eventType, evt);
            }
        };
        this._originalOffsets = new WeakMap();
        this.meshingLookBehind = (_a = options.meshingLookBehind) !== null && _a !== void 0 ? _a : this.meshingLookBehind;
        this.addComponent(new TransformComponent());
        this.addComponent(new MotionComponent());
        this.addComponent(new BodyComponent({
            type: CollisionType.Fixed
        }));
        this.addComponent(new GraphicsComponent({
            onPostDraw: (ctx, delta) => this.draw(ctx, delta)
        }));
        this.addComponent(new DebugGraphicsComponent((ctx, debugFlags) => this.debug(ctx, debugFlags), false));
        this.addComponent(new ColliderComponent());
        this.addComponent(new PointerComponent());
        this.pointer = this.get(PointerComponent);
        this._graphics = this.get(GraphicsComponent);
        this.transform = this.get(TransformComponent);
        this._motion = this.get(MotionComponent);
        this.collider = this.get(ColliderComponent);
        this._composite = this.collider.useCompositeCollider([]);
        this.transform.pos = (_b = options.pos) !== null && _b !== void 0 ? _b : Vector.Zero;
        this._oldPos = this.transform.pos.clone();
        this._oldScale = this.transform.scale.clone();
        this.renderFromTopOfGraphic = (_c = options.renderFromTopOfGraphic) !== null && _c !== void 0 ? _c : this.renderFromTopOfGraphic;
        this.tileWidth = options.tileWidth;
        this.tileHeight = options.tileHeight;
        this.rows = options.rows;
        this.columns = options.columns;
        this.tiles = new Array(this.rows * this.columns);
        this._rows = new Array(this.rows);
        this._cols = new Array(this.columns);
        let currentCol = [];
        for (let i = 0; i < this.columns; i++) {
            for (let j = 0; j < this.rows; j++) {
                const tile = new Tile({
                    x: i,
                    y: j,
                    map: this
                });
                tile.map = this;
                this.tiles[i + j * this.columns] = tile;
                currentCol.push(tile);
                if (!this._rows[j]) {
                    this._rows[j] = [];
                }
                this._rows[j].push(tile);
            }
            this._cols[i] = currentCol;
            currentCol = [];
        }
        this._setupPointerToTile();
        this._graphics.localBounds = new BoundingBox({
            left: 0,
            top: 0,
            right: this.columns * this.tileWidth * this.scale.x,
            bottom: this.rows * this.tileHeight * this.scale.y
        });
    }
    _initialize(engine) {
        super._initialize(engine);
        this._engine = engine;
    }
    _setupPointerToTile() {
        this.events.on('pointerup', this._forwardPointerEventToTile('pointerup'));
        this.events.on('pointerdown', this._forwardPointerEventToTile('pointerdown'));
        this.events.on('pointermove', this._forwardPointerEventToTile('pointermove'));
        this.events.on('pointercancel', this._forwardPointerEventToTile('pointercancel'));
    }
    _getOrSetColliderOriginalOffset(collider) {
        if (!this._originalOffsets.has(collider)) {
            const originalOffset = collider.offset;
            this._originalOffsets.set(collider, originalOffset);
            return originalOffset;
        }
        else {
            return this._originalOffsets.get(collider);
        }
    }
    /**
     * Tiles colliders based on the solid tiles in the tilemap.
     */
    _updateColliders() {
        this.collider.$colliderRemoved.notifyAll(this._composite);
        this._composite.clearColliders();
        const colliders = [];
        this._composite = this.collider.useCompositeCollider([]);
        let current;
        /**
         * Returns wether or not the 2 boxes share an edge and are the same height
         * @param prev
         * @param next
         * @returns true if they share and edge, false if not
         */
        const shareEdges = (prev, next) => {
            if (prev && next) {
                // same top/bottom
                return (prev.top === next.top &&
                    prev.bottom === next.bottom &&
                    // Shared right/left edge
                    prev.right === next.left);
            }
            return false;
        };
        /**
         * Potentially merges the current collider into a list of previous ones, mutating the list
         * If checkAndCombine returns true, the collider was successfully merged and should be thrown away
         * @param current current collider to test
         * @param colliders List of colliders to consider merging with
         * @param maxLookBack The amount of colliders to look back for combination
         * @returns false when no combination found, true when successfully combined
         */
        const checkAndCombine = (current, colliders, maxLookBack = this.meshingLookBehind) => {
            if (!current) {
                return false;
            }
            // walk backwards through the list of colliders and combine with the first that shares an edge
            for (let i = colliders.length - 1; i >= 0; i--) {
                if (maxLookBack-- < 0) {
                    // blunt the O(n^2) algorithm a bit
                    return false;
                }
                const prev = colliders[i];
                if (shareEdges(prev, current)) {
                    colliders[i] = prev.combine(current);
                    return true;
                }
            }
            return false;
        };
        // ? configurable bias perhaps, horizontal strips vs. vertical ones
        // Bad tile collider packing algorithm
        for (let i = 0; i < this.columns; i++) {
            // Scan column for colliders
            for (let j = 0; j < this.rows; j++) {
                const tile = this.tiles[i + j * this.columns];
                // Current tile in column is solid build up current collider
                if (tile.solid) {
                    // Use custom collider otherwise bounding box
                    if (tile.getColliders().length > 0) {
                        // tile with custom collider interrupting the current run
                        for (const collider of tile.getColliders()) {
                            const originalOffset = this._getOrSetColliderOriginalOffset(collider);
                            collider.offset = vec(tile.x * this.tileWidth * this.scale.x, tile.y * this.tileHeight * this.scale.y).add(originalOffset);
                            collider.owner = this;
                            this._composite.addCollider(collider);
                        }
                        //we push any current collider before nulling the current run
                        if (current && !checkAndCombine(current, colliders)) {
                            colliders.push(current);
                        }
                        current = null;
                        // Use the bounding box
                    }
                    else {
                        if (!current) {
                            // no current run, start one
                            current = tile.defaultGeometry;
                        }
                        else {
                            // combine with current run
                            current = current.combine(tile.defaultGeometry);
                        }
                    }
                }
                else {
                    // Not solid skip and cut off the current collider
                    // End of run check and combine
                    if (current && !checkAndCombine(current, colliders)) {
                        colliders.push(current);
                    }
                    current = null;
                }
            }
            // After a column is complete check to see if it can be merged into the last one
            // Eno of run check and combine
            if (current && !checkAndCombine(current, colliders)) {
                // else new collider if no combination
                colliders.push(current);
            }
            current = null;
        }
        for (const c of colliders) {
            const collider = Shape.Box(c.width, c.height, Vector.Zero, vec(c.left - this.pos.x, c.top - this.pos.y));
            collider.owner = this;
            this._composite.addCollider(collider);
        }
        this.collider.update();
        // Notify that colliders have been updated
        this.collider.$colliderAdded.notifyAll(this._composite);
    }
    /**
     * Returns the [[Tile]] by index (row major order)
     */
    getTileByIndex(index) {
        return this.tiles[index];
    }
    /**
     * Returns the [[Tile]] by its x and y integer coordinates
     *
     * For example, if I want the tile in fifth column (x), and second row (y):
     * `getTile(4, 1)` 0 based, so 0 is the first in row/column
     */
    getTile(x, y) {
        if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
            return null;
        }
        return this.tiles[x + y * this.columns];
    }
    /**
     * Returns the [[Tile]] by testing a point in world coordinates,
     * returns `null` if no Tile was found.
     */
    getTileByPoint(point) {
        const { x, y } = this._getTileCoordinates(point);
        const tile = this.getTile(x, y);
        if (x >= 0 && y >= 0 && x < this.columns && y < this.rows && tile) {
            return tile;
        }
        return null;
    }
    _getTileCoordinates(point) {
        // Convert to Tile Space point
        point = this.transform.applyInverse(point);
        const x = Math.floor(point.x / this.tileWidth);
        const y = Math.floor(point.y / this.tileHeight);
        return { x, y };
    }
    getRows() {
        return this._rows;
    }
    getColumns() {
        return this._cols;
    }
    /**
     * Returns the on screen tiles for a tilemap, this will overshoot by a small amount because of the internal quad tree data structure.
     *
     * Useful if you need to perform specific logic on onscreen tiles
     */
    getOnScreenTiles() {
        let worldBounds = this._engine.screen.getWorldBounds();
        const maybeParallax = this.get(ParallaxComponent);
        if (maybeParallax && this.isInitialized) {
            let pos = this.pos;
            const oneMinusFactor = Vector.One.sub(maybeParallax.parallaxFactor);
            const parallaxOffset = this._engine.currentScene.camera.pos.scale(oneMinusFactor);
            pos = pos.sub(parallaxOffset);
            // adjust world bounds by parallax factor
            worldBounds = worldBounds.translate(pos);
        }
        const bounds = this.transform.coordPlane === CoordPlane.Screen ? this._engine.screen.getScreenBounds() : worldBounds;
        const topLeft = this._getTileCoordinates(bounds.topLeft);
        const topRight = this._getTileCoordinates(bounds.topRight);
        const bottomRight = this._getTileCoordinates(bounds.bottomRight);
        const bottomLeft = this._getTileCoordinates(bounds.bottomLeft);
        const tileStartX = Math.min(clamp(topLeft.x, 0, this.columns - 1), clamp(topRight.x, 0, this.columns - 1));
        const tileStartY = Math.min(clamp(topLeft.y, 0, this.rows - 1), clamp(topRight.y, 0, this.rows - 1));
        const tileEndX = Math.max(clamp(bottomRight.x, 0, this.columns - 1), clamp(bottomLeft.x, 0, this.columns - 1));
        const tileEndY = Math.max(clamp(bottomRight.y, 0, this.rows - 1), clamp(bottomLeft.y, 0, this.rows - 1));
        const tiles = [];
        for (let x = tileStartX; x <= tileEndX; x++) {
            for (let y = tileStartY; y <= tileEndY; y++) {
                tiles.push(this.getTile(x, y));
            }
        }
        return tiles;
    }
    update(engine, delta) {
        this._initialize(engine);
        this.onPreUpdate(engine, delta);
        this.emit('preupdate', new PreUpdateEvent(engine, delta, this));
        if (!this._oldPos.equals(this.pos) || this._oldRotation !== this.rotation || !this._oldScale.equals(this.scale)) {
            this.flagCollidersDirty();
            this.flagTilesDirty();
        }
        if (this._collidersDirty) {
            this._collidersDirty = false;
            this._updateColliders();
        }
        this._token++;
        this.pos.clone(this._oldPos);
        this._oldRotation = this.rotation;
        this.scale.clone(this._oldScale);
        this.transform.pos = this.pos;
        this.onPostUpdate(engine, delta);
        this.emit('postupdate', new PostUpdateEvent(engine, delta, this));
    }
    /**
     * Draws the tile map to the screen. Called by the [[Scene]].
     * @param ctx ExcaliburGraphicsContext
     * @param delta  The number of milliseconds since the last draw
     */
    draw(ctx, delta) {
        if (!this.isInitialized) {
            return;
        }
        this.emit('predraw', new PreDrawEvent(ctx, delta, this)); // TODO fix event
        let graphics, graphicsIndex, graphicsLen;
        const tiles = this.getOnScreenTiles();
        for (let i = 0; i < tiles.length; i++) {
            const tile = tiles[i];
            // get non-negative tile sprites
            const offsets = tile.getGraphicsOffsets();
            graphics = tile.getGraphics();
            for (graphicsIndex = 0, graphicsLen = graphics.length; graphicsIndex < graphicsLen; graphicsIndex++) {
                // draw sprite, warning if sprite doesn't exist
                const graphic = graphics[graphicsIndex];
                const offset = offsets[graphicsIndex];
                if (graphic) {
                    if (hasGraphicsTick(graphic)) {
                        graphic === null || graphic === void 0 ? void 0 : graphic.tick(delta, this._token);
                    }
                    const offsetY = this.renderFromTopOfGraphic ? 0 : graphic.height - this.tileHeight;
                    graphic.draw(ctx, tile.x * this.tileWidth + offset.x, tile.y * this.tileHeight - offsetY + offset.y);
                }
            }
        }
        this.emit('postdraw', new PostDrawEvent(ctx, delta, this));
    }
    debug(gfx, debugFlags) {
        const { showAll, showGrid, gridColor, gridWidth, showSolidBounds: showColliderBounds, solidBoundsColor: colliderBoundsColor, showColliderGeometry } = debugFlags.tilemap;
        const { geometryColor, geometryLineWidth, geometryPointSize } = debugFlags.collider;
        const width = this.tileWidth * this.columns * this.scale.x;
        const height = this.tileHeight * this.rows * this.scale.y;
        const pos = this.pos;
        if (showGrid || showAll) {
            for (let r = 0; r < this.rows + 1; r++) {
                const yOffset = vec(0, r * this.tileHeight * this.scale.y);
                gfx.drawLine(pos.add(yOffset), pos.add(vec(width, yOffset.y)), gridColor, gridWidth);
            }
            for (let c = 0; c < this.columns + 1; c++) {
                const xOffset = vec(c * this.tileWidth * this.scale.x, 0);
                gfx.drawLine(pos.add(xOffset), pos.add(vec(xOffset.x, height)), gridColor, gridWidth);
            }
        }
        if (showAll || showColliderBounds || showColliderGeometry) {
            const colliders = this._composite.getColliders();
            gfx.save();
            gfx.translate(this.pos.x, this.pos.y);
            gfx.scale(this.scale.x, this.scale.y);
            for (const collider of colliders) {
                const bounds = collider.localBounds;
                const pos = collider.worldPos.sub(this.pos);
                if (showColliderBounds) {
                    gfx.drawRectangle(pos, bounds.width, bounds.height, colliderBoundsColor);
                }
            }
            gfx.restore();
            if (showColliderGeometry) {
                for (const collider of colliders) {
                    collider.debug(gfx, geometryColor, { lineWidth: geometryLineWidth, pointSize: geometryPointSize });
                }
            }
        }
        if (showAll || showColliderBounds) {
            gfx.save();
            gfx.z = 999;
            if (showColliderBounds) {
                for (let i = 0; i < this.tiles.length; i++) {
                    this.tiles[i].bounds.draw(gfx);
                }
            }
            gfx.restore();
        }
    }
}
/**
 * TileMap Tile
 *
 * A light-weight object that occupies a space in a collision map. Generally
 * created by a [[TileMap]].
 *
 * Tiles can draw multiple sprites. Note that the order of drawing is the order
 * of the sprites in the array so the last one will be drawn on top. You can
 * use transparency to create layers this way.
 */
export class Tile {
    /**
     * Return the world position of the top left corner of the tile
     */
    get pos() {
        if (this._posDirty) {
            this._recalculate();
            this._posDirty = false;
        }
        return this._pos;
    }
    /**
     * Width of the tile in pixels
     */
    get width() {
        return this._width;
    }
    /**
     * Height of the tile in pixels
     */
    get height() {
        return this._height;
    }
    /**
     * Wether this tile should be treated as solid by the tilemap
     */
    get solid() {
        return this._solid;
    }
    /**
     * Wether this tile should be treated as solid by the tilemap
     */
    set solid(val) {
        var _a;
        (_a = this.map) === null || _a === void 0 ? void 0 : _a.flagCollidersDirty();
        this._solid = val;
    }
    /**
     * Current list of graphics for this tile
     */
    getGraphics() {
        return this._graphics;
    }
    /**
     * Current list of offsets for this tile's graphics
     */
    getGraphicsOffsets() {
        return this._offsets;
    }
    /**
     * Add another [[Graphic]] to this TileMap tile
     * @param graphic
     */
    addGraphic(graphic, options) {
        this._graphics.push(graphic);
        if (options === null || options === void 0 ? void 0 : options.offset) {
            this._offsets.push(options.offset);
        }
        else {
            this._offsets.push(Vector.Zero);
        }
    }
    /**
     * Remove an instance of a [[Graphic]] from this tile
     */
    removeGraphic(graphic) {
        const index = this._graphics.indexOf(graphic);
        if (index > -1) {
            this._graphics.splice(index, 1);
            this._offsets.splice(index, 1);
        }
    }
    /**
     * Clear all graphics from this tile
     */
    clearGraphics() {
        this._graphics.length = 0;
        this._offsets.length = 0;
    }
    /**
     * Returns the list of colliders
     */
    getColliders() {
        return this._colliders;
    }
    /**
     * Adds a custom collider to the [[Tile]] to use instead of it's bounds
     *
     * If no collider is set but [[Tile.solid]] is set, the tile bounds are used as a collider.
     *
     * **Note!** the [[Tile.solid]] must be set to true for it to act as a "fixed" collider
     * @param collider
     */
    addCollider(collider) {
        this._colliders.push(collider);
        this.map.flagCollidersDirty();
    }
    /**
     * Removes a collider from the [[Tile]]
     * @param collider
     */
    removeCollider(collider) {
        const index = this._colliders.indexOf(collider);
        if (index > -1) {
            this._colliders.splice(index, 1);
        }
        this.map.flagCollidersDirty();
    }
    /**
     * Clears all colliders from the [[Tile]]
     */
    clearColliders() {
        this._colliders.length = 0;
        this.map.flagCollidersDirty();
    }
    constructor(options) {
        var _a, _b;
        this._posDirty = false;
        this.events = new EventEmitter();
        this._solid = false;
        this._graphics = [];
        this._offsets = [];
        /**
         * Current list of colliders for this tile
         */
        this._colliders = [];
        /**
         * Arbitrary data storage per tile, useful for any game specific data
         */
        this.data = new Map();
        this.x = options.x;
        this.y = options.y;
        this.map = options.map;
        this._width = options.map.tileWidth * this.map.scale.x;
        this._height = options.map.tileHeight * this.map.scale.y;
        this.solid = (_a = options.solid) !== null && _a !== void 0 ? _a : this.solid;
        this._graphics = (_b = options.graphics) !== null && _b !== void 0 ? _b : [];
        this._recalculate();
    }
    flagDirty() {
        return (this._posDirty = true);
    }
    _recalculate() {
        const geometryPos = this.map.pos.add(vec(this.x * this.map.tileWidth, this.y * this.map.tileHeight));
        this._geometry = new BoundingBox(geometryPos.x, geometryPos.y, geometryPos.x + this.map.tileWidth, geometryPos.y + this.map.tileHeight);
        this._width = this.map.tileWidth * this.map.scale.x;
        this._height = this.map.tileHeight * this.map.scale.y;
        this._pos = this.map.pos.add(vec(this.x * this._width, this.y * this._height));
        this._bounds = new BoundingBox(this._pos.x, this._pos.y, this._pos.x + this._width, this._pos.y + this._height);
        if (this.map.rotation) {
            this._bounds = this._bounds.rotate(this.map.rotation, this.map.pos);
        }
        this._posDirty = false;
    }
    /**
     * Tile bounds in world space
     */
    get bounds() {
        if (this._posDirty) {
            this._recalculate();
        }
        return this._bounds;
    }
    get defaultGeometry() {
        return this._geometry;
    }
    /**
     * Tile position in world space
     */
    get center() {
        if (this._posDirty) {
            this._recalculate();
        }
        return new Vector(this._pos.x + this._width / 2, this._pos.y + this._height / 2);
    }
    emit(eventName, event) {
        this.events.emit(eventName, event);
    }
    on(eventName, handler) {
        return this.events.on(eventName, handler);
    }
    once(eventName, handler) {
        return this.events.once(eventName, handler);
    }
    off(eventName, handler) {
        if (handler) {
            this.events.off(eventName, handler);
        }
        else {
            this.events.off(eventName);
        }
    }
}
//# sourceMappingURL=TileMap.js.map