/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

import type TileCoordinate from './TileCoordinate';

const TOP = 0;
const TOP_RIGHT = 1;
const RIGHT = 2;
const BOTTOM_RIGHT = 3;
const BOTTOM = 4;
const BOTTOM_LEFT = 5;
const LEFT = 6;
const TOP_LEFT = 7;

export interface Tile {
    /**
     * The unique ID of the tile.
     */
    id: number;
    /**
     * The tile's coordinate in the tile matrix.
     */
    coordinate: TileCoordinate;
}

export type NeighbourList<T extends Tile> = [
    T | null,
    T | null,
    T | null,
    T | null,
    T | null,
    T | null,
    T | null,
    T | null,
];
export type Predicate<T extends Tile> = (tile: T) => boolean;

class TileIndex<T extends Tile> {
    public tiles: Map<string, WeakRef<T>>;
    public tilesById: Map<number, WeakRef<T>>;
    public constructor() {
        this.tiles = new Map();
        this.tilesById = new Map();
    }

    /**
     * Adds a tile to the index.
     *
     * @param tile - the tile to add.
     */
    public addTile(tile: T): void {
        const { x, y, z } = tile.coordinate;
        const key = TileIndex.getKey(x, y, z);
        const wr = new WeakRef(tile);
        this.tiles.set(key, wr);
        this.tilesById.set(tile.id, wr);
    }

    /**
     * Gets a tile by its ID.
     *
     * @param id - The ID.
     * @returns The found tile, otherwise undefined.
     */
    public getTile(id: number): T | undefined {
        const entry = this.tilesById.get(id);
        if (entry) {
            const value = entry.deref();
            if (value) {
                return value;
            }
        }

        return undefined;
    }

    public static getKey(x: number, y: number, z: number): string {
        return `${x},${y},${z}`;
    }

    /**
     * Returns an array containing the 8 possible neighbours of a tile.
     * A neighbor is a tile at the same level or higher level located according to the clock order
     * from north:
     *
     * ```
     * 7 : north west -- 0 : north -- 1 : north east
     * 6 : west       -- THE  TILE -- 2 : east
     * 5 : south west -- 4 : south -- 3 : south east
     * ```
     *
     * If there is no neighbor, if it isn't visible or if it is a smaller level one, return null.
     *
     * @param tile - the tile to query
     * @returns neighbors : Array of found neighbors
     */
    public getNeighbours(
        tile: T,
        result: NeighbourList<T>,
        predicate?: Predicate<T>,
    ): NeighbourList<T> {
        const { x, y, z } = tile.coordinate;

        result[TOP] = this.searchTileOrAncestor(x, y + 1, z, predicate);
        result[TOP_RIGHT] = this.searchTileOrAncestor(x + 1, y + 1, z, predicate);
        result[RIGHT] = this.searchTileOrAncestor(x + 1, y, z, predicate);
        result[BOTTOM_RIGHT] = this.searchTileOrAncestor(x + 1, y - 1, z, predicate);
        result[BOTTOM] = this.searchTileOrAncestor(x, y - 1, z, predicate);
        result[BOTTOM_LEFT] = this.searchTileOrAncestor(x - 1, y - 1, z, predicate);
        result[LEFT] = this.searchTileOrAncestor(x - 1, y, z, predicate);
        result[TOP_LEFT] = this.searchTileOrAncestor(x - 1, y + 1, z, predicate);

        return result;
    }

    public static getParent(x: number, y: number, z: number): TileCoordinate | null {
        if (z === 0) {
            return null;
        }

        const newX = Math.floor(x / 2);
        const newY = Math.floor(y / 2);
        const newZ = z - 1;
        return { x: newX, y: newY, z: newZ };
    }

    public update(): void {
        // Remove obsolete entries
        const keys = [...this.tiles.keys()];
        for (const key of keys) {
            const entry = this.tiles.get(key);
            if (entry && !entry.deref()) {
                this.tiles.delete(key);
            }
        }
        const ids = [...this.tilesById.keys()];
        for (const key of ids) {
            const entry = this.tilesById.get(key);
            if (entry && !entry.deref()) {
                this.tilesById.delete(key);
            }
        }
    }

    /**
     * Search for the specific tile by coordinates if any, or any valid ancestor.
     *
     * @param x - The tile X coordinate.
     * @param y - The tile Y coordinate.
     * @param z - The tile Z coordinate (zoom level).
     * @returns The matching tile if found, null otherwise.
     */
    public searchTileOrAncestor(
        x: number,
        y: number,
        z: number,
        predicate?: Predicate<T>,
    ): T | null {
        const key = TileIndex.getKey(x, y, z);
        const entry = this.tiles.get(key);

        if (entry) {
            const n = entry.deref();

            if (n && (typeof predicate !== 'function' || predicate(n))) {
                return n;
            }
        }

        const parent = TileIndex.getParent(x, y, z);
        if (!parent) {
            return null;
        }

        return this.searchTileOrAncestor(parent.x, parent.y, parent.z, predicate);
    }
}

export default TileIndex;
export { BOTTOM, BOTTOM_LEFT, BOTTOM_RIGHT, LEFT, RIGHT, TOP, TOP_LEFT, TOP_RIGHT };
