// @flow

import Tile from './tile.js';
import window from '../util/window.js';
import RasterTileSource from './raster_tile_source.js';
import {extend, pick} from '../util/util.js';
import {Event, ErrorEvent, Evented} from '../util/evented.js';

import type Map from '../ui/mapbox_map.js';
import type Dispatcher from '../util/dispatcher.js';
import type {Source} from './source.js';
import type {Callback} from '../types/callback.js';

type DataType = 'raster';

function isRaster(data: any): boolean {
    return data instanceof window.ImageBitmap || data instanceof window.HTMLCanvasElement;
}

/**
 * Interface for custom sources. This is a specification for
 * implementers to model: it is not an exported method or class.
 *
 * Custom sources allow a user to load and modify their own tiles.
 * These sources can be added between any regular sources using {@link Map#addSource}.
 *
 * Custom sources must have a unique `id` and must have the `type` of `"custom"`.
 * They must implement `loadTile` and may implement `unloadTile`, `prepareTile`, `onAdd` and `onRemove`.
 * They can trigger rendering using {@link Map#triggerRepaint}.
 *
 * @interface CustomSourceInterface
 * @property {string} id A unique source id.
 * @property {string} type The source's type. Must be `"custom"`.
 * @example
 * // Custom source implemented as ES6 class
 * class CustomSource {
 *     constructor() {
 *         this.id = 'custom-source';
 *         this.type = 'custom';
 *         this.tileSize = 256;
 *         this.tilesUrl = 'https://stamen-tiles.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg';
 *         this.attribution = 'Map tiles by Stamen Design, under CC BY 3.0';
 *     }
 *
 *     async loadTile(tile, {signal}) {
 *         const url = this.tilesUrl
 *             .replace('{z}', String(tile.z))
 *             .replace('{x}', String(tile.x))
 *             .replace('{y}', String(tile.y));
 *
 *         const response = await fetch(url, {signal});
 *         const data = await response.arrayBuffer();
 *
 *         const blob = new window.Blob([new Uint8Array(data)], {type: 'image/png'});
 *         const imageBitmap = await window.createImageBitmap(blob);
 *
 *         return imageBitmap;
 *     }
 * }
 *
 * map.on('load', () => {
 *     map.addSource('custom-source', new CustomSource());
 *     map.addLayer({
 *         id: 'layer',
 *         type: 'raster',
 *         source: 'custom-source'
 *     });
 * });
 */

/**
 * Optional method called when the source has been added to the Map with {@link Map#addSource}.
 * This gives the source a chance to initialize resources and register event listeners.
 *
 * @function
 * @memberof CustomSourceInterface
 * @instance
 * @name onAdd
 * @param {Map} map The Map this custom source was just added to.
 */

/**
 * Optional method called when the source has been removed from the Map with {@link Map#removeSource}.
 * This gives the source a chance to clean up resources and event listeners.
 *
 * @function
 * @memberof CustomSourceInterface
 * @instance
 * @name onRemove
 * @param {Map} map The Map this custom source was added to.
 */

/**
 * Optional method called after the tile is unloaded from the map viewport. This
 * gives the source a chance to clean up resources and event listeners.
 *
 * @function
 * @memberof CustomSourceInterface
 * @instance
 * @name unloadTile
 * @param {{ z: number, x: number, y: number }} tile Tile name to unload in the XYZ scheme format.
 */

/**
 * Optional method called during a render frame to allow a source to prepare and modify a tile texture if needed.
 *
 * @function
 * @memberof CustomSourceInterface
 * @instance
 * @name prepareTile
 * @param {{ z: number, x: number, y: number }} tile Tile name to prepare in the XYZ scheme format.
 * @returns {TextureImage} The tile image data as an `HTMLImageElement`, `ImageData`, `ImageBitmap` or object with `width`, `height`, and `data`.
 */

/**
 * Called when the map starts loading tile for the current animation frame.
 *
 * @function
 * @memberof CustomSourceInterface
 * @instance
 * @name loadTile
 * @param {{ z: number, x: number, y: number }} tile Tile name to load in the XYZ scheme format.
 * @param {Object} options Options.
 * @param {AbortSignal} options.signal A signal object that allows the map to cancel tile loading request.
 * @returns {Promise<TextureImage>} The tile image data as an `HTMLImageElement`, `ImageData`, `ImageBitmap` or object with `width`, `height`, and `data`.
 */
export type CustomSourceInterface<T> = {
    id: string;
    type: 'custom',
    dataType: ?DataType,
    minzoom: ?number,
    maxzoom: ?number,
    scheme: ?string;
    tileSize: ?number,
    attribution: ?string,
    loadTile: (tileID: { z: number, x: number, y: number }, options: { signal: AbortSignal }) => Promise<T>,
    prepareTile: ?(tileID: { z: number, x: number, y: number }) => ?T,
    unloadTile: ?(tileID: { z: number, x: number, y: number }) => void,
    onAdd: ?(map: Map) => void,
    onRemove: ?(map: Map) => void,
}

class CustomSource<T> extends Evented implements Source {

    id: string;
    type: 'custom';
    scheme: string;
    minzoom: number;
    maxzoom: number;
    tileSize: number;
    attribution: string;
    roundZoom: boolean;

    minTileCacheSize: ?number;
    maxTileCacheSize: ?number;

    _dataType: ?DataType;
    _implementation: CustomSourceInterface<T>;

    _map: Map;
    _loaded: boolean;
    _dispatcher: Dispatcher;

    constructor(id: string, implementation: CustomSourceInterface<T>, dispatcher: Dispatcher, eventedParent: Evented) {
        super();
        this.id = id;
        this.type = 'custom';
        this._dataType = 'raster';
        this._dispatcher = dispatcher;
        this._implementation = implementation;
        this.setEventedParent(eventedParent);

        this.scheme = 'xyz';
        this.minzoom = 0;
        this.maxzoom = 22;
        this.tileSize = 512;

        this._loaded = false;
        this.roundZoom = true;

        if (!this._implementation) {
            this.fire(new ErrorEvent(new Error(`Missing implementation for ${this.id} custom source`)));
        }

        if (!this._implementation.loadTile) {
            this.fire(new ErrorEvent(new Error(`Missing loadTile implementation for ${this.id} custom source`)));
        }

        // $FlowFixMe[prop-missing]
        implementation.update = this._update.bind(this);

        // $FlowFixMe[prop-missing]
        implementation.coveringTiles = this._coveringTiles.bind(this);

        extend(this, pick(implementation, ['dataType', 'scheme', 'minzoom', 'maxzoom', 'tileSize', 'attribution', 'minTileCacheSize', 'maxTileCacheSize']));
    }

    serialize(): Source {
        return pick(this, ['type', 'scheme', 'minzoom', 'maxzoom', 'tileSize', 'attribution']);
    }

    load() {
        this._loaded = true;
        this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
        this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
    }

    loaded(): boolean {
        return this._loaded;
    }

    onAdd(map: Map): void {
        this._map = map;
        this._loaded = false;
        this.fire(new Event('dataloading', {dataType: 'source'}));
        if (this._implementation.onAdd) this._implementation.onAdd(map);
        this.load();
    }

    onRemove(map: Map): void {
        if (this._implementation.onRemove) {
            this._implementation.onRemove(map);
        }
    }

    loadTile(tile: Tile, callback: Callback<void>) {
        const {x, y, z} = tile.tileID.canonical;
        const controller = new window.AbortController();
        const signal = controller.signal;

        // $FlowFixMe[prop-missing]
        tile.request = this._implementation.loadTile({x, y, z}, {signal})
            .then(tileLoaded.bind(this))
            .catch(error => {
                // silence AbortError and 404 errors
                if (error.code === 20 || error.code === 404) return;
                tile.state = 'errored';
                callback(error);
            });

        // $FlowFixMe[prop-missing]
        tile.request.cancel = () => controller.abort();

        function tileLoaded(data) {
            delete tile.request;

            if (tile.aborted) {
                tile.state = 'unloaded';
                return callback(null);
            }

            if (!data) return callback(null);
            if (!isRaster(data)) return callback(new Error(`Can't infer data type for ${this.id}, only raster data supported at the moment`));

            this.loadTileData(tile, data);
            tile.state = 'loaded';
            callback(null);
        }
    }

    loadTileData(tile: Tile, data: T): void {
        // Only raster data supported at the moment
        RasterTileSource.loadTileData(tile, (data: any), this._map.painter);
    }

    unloadTileData(tile: Tile): void {
        // Only raster data supported at the moment
        RasterTileSource.unloadTileData(tile, this._map.painter);
    }

    prepareTile(tile: Tile): ?T {
        if (!this._implementation.prepareTile) return null;

        const {x, y, z} = tile.tileID.canonical;
        const data = this._implementation.prepareTile({x, y, z});
        if (!data) return null;

        RasterTileSource.loadTileData(tile, (data: any), this._map.painter);
        return data;
    }

    unloadTile(tile: Tile, callback: Callback<void>): void {
        RasterTileSource.unloadTileData(tile, this._map.painter);
        if (this._implementation.unloadTile) {
            const {x, y, z} = tile.tileID.canonical;
            this._implementation.unloadTile({x, y, z});
        }

        callback();
    }

    abortTile(tile: Tile, callback: Callback<void>): void {
        if (tile.request && tile.request.cancel) {
            tile.request.cancel();
            delete tile.request;
        }

        callback();
    }

    hasTransition(): boolean {
        return false;
    }

    _coveringTiles(): { z: number, x: number, y: number }[] {
        const tileIDs = this._map.transform.coveringTiles({
            tileSize: this.tileSize,
            minzoom: this.minzoom,
            maxzoom: this.maxzoom,
        });

        return tileIDs.map(tileID => ({x: tileID.canonical.x, y: tileID.canonical.y, z: tileID.canonical.z}));
    }

    _update() {
        this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
    }
}

export default CustomSource;
