import {ImageSource} from './image_source.ts';

import {Texture} from '../webgl/texture.ts';
import {ErrorEvent} from '../util/evented.ts';
import {MapSourceDataEvent} from '../ui/events.ts';
import {ValidationError} from '@maplibre/maplibre-gl-style-spec';

import type {Map} from '../ui/map.ts';
import type {Dispatcher} from '../util/dispatcher.ts';
import type {Evented} from '../util/evented.ts';

/**
 * Options to add a canvas source type to the map.
 */
export type CanvasSourceSpecification = {
    /**
     * Source type. Must be `"canvas"`.
     */
    type: 'canvas';
    /**
     * Four geographical coordinates denoting where to place the corners of the canvas, specified in `[longitude, latitude]` pairs.
     */
    coordinates: [[number, number], [number, number], [number, number], [number, number]];
    /**
     * Whether the canvas source is animated. If the canvas is static (i.e. pixels do not need to be re-read on every frame), `animate` should be set to `false` to improve performance.
     * @defaultValue true
     */
    animate?: boolean;
    /**
     * Canvas source from which to read pixels. Can be a string representing the ID of the canvas element, or the `HTMLCanvasElement` itself.
     */
    canvas?: string | HTMLCanvasElement;
};

/**
 * A data source containing the contents of an HTML canvas. See {@link CanvasSourceSpecification} for detailed documentation of options.
 *
 * @group Sources
 *
 * @example
 * ```ts
 * // add to map
 * map.addSource('some id', {
 *    type: 'canvas',
 *    canvas: 'idOfMyHTMLCanvas',
 *    animate: true,
 *    coordinates: [
 *        [-76.54, 39.18],
 *        [-76.52, 39.18],
 *        [-76.52, 39.17],
 *        [-76.54, 39.17]
 *    ]
 * });
 *
 * // update
 * let mySource = map.getSource('some id');
 * mySource.setCoordinates([
 *     [-76.54335737228394, 39.18579907229748],
 *     [-76.52803659439087, 39.1838364847587],
 *     [-76.5295386314392, 39.17683392507606],
 *     [-76.54520273208618, 39.17876344106642]
 * ]);
 *
 * map.removeSource('some id');  // remove
 * ```
 */
export class CanvasSource extends ImageSource {
    options: CanvasSourceSpecification;
    animate: boolean;
    canvas: HTMLCanvasElement;
    width: number;
    height: number;
    /**
     * Enables animation. The image will be copied from the canvas to the map on each frame.
     */
    play: () => void;
    /**
     * Disables animation. The map will display a static copy of the canvas image.
     */
    pause: () => void;
    _playing: boolean;

    /** @internal */
    constructor(id: string, options: CanvasSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) {
        super(id, options, dispatcher, eventedParent);

        // We build in some validation here, since canvas sources aren't included in the style spec:
        if (!options.coordinates) {
            this.fire(new ErrorEvent(new ValidationError(`sources.${id}`, null, 'missing required property "coordinates"')));
        } else if (!Array.isArray(options.coordinates) || options.coordinates.length !== 4 ||
                options.coordinates.some(c => !Array.isArray(c) || c.length !== 2 || c.some(l => typeof l !== 'number'))) {
            this.fire(new ErrorEvent(new ValidationError(`sources.${id}`, null, '"coordinates" property must be an array of 4 longitude/latitude array pairs')));
        }

        if (options.animate && typeof options.animate !== 'boolean') {
            this.fire(new ErrorEvent(new ValidationError(`sources.${id}`, null, 'optional "animate" property must be a boolean value')));
        }

        if (!options.canvas) {
            this.fire(new ErrorEvent(new ValidationError(`sources.${id}`, null, 'missing required property "canvas"')));
        } else if (typeof options.canvas !== 'string' && !(options.canvas instanceof HTMLCanvasElement)) {
            this.fire(new ErrorEvent(new ValidationError(`sources.${id}`, null, '"canvas" must be either a string representing the ID of the canvas element from which to read, or an HTMLCanvasElement instance')));
        }

        this.options = options;
        this.animate = options.animate !== undefined ? options.animate : true;
    }

    async load(): Promise<void> {
        this._loaded = true;
        this.canvas ||= (this.options.canvas instanceof HTMLCanvasElement) ?
            this.options.canvas :
            document.getElementById(this.options.canvas) as HTMLCanvasElement;
        // cast to HTMLCanvasElement in else of ternary
        // should we do a safety check and throw if it's not actually HTMLCanvasElement?
        this.width = this.canvas.width;
        this.height = this.canvas.height;

        if (this._hasInvalidDimensions()) {
            this.fire(new ErrorEvent(new Error('Canvas dimensions cannot be less than or equal to zero.')));
            return;
        }

        this.play = function() {
            this._playing = true;
            this.map.triggerRepaint();
        };

        this.pause = function() {
            if (this._playing) {
                this.prepare();
                this._playing = false;
            }
        };

        this._finishLoading();
    }

    /**
     * Returns the HTML `canvas` element.
     *
     * @returns The HTML `canvas` element.
     */
    getCanvas(): HTMLCanvasElement {
        return this.canvas;
    }

    onAdd(map: Map): void {
        this.map = map;
        this.load();
        if (this.canvas) {
            if (this.animate) this.play();
        }
    }

    onRemove(): void {
        this._playing = false;
        super.onRemove();
    }

    prepare(): void {
        let resize = false;
        if (this.canvas.width !== this.width) {
            this.width = this.canvas.width;
            resize = true;
        }
        if (this.canvas.height !== this.height) {
            this.height = this.canvas.height;
            resize = true;
        }

        if (this._hasInvalidDimensions()) return;

        if (Object.keys(this.tiles).length === 0) return; // not enough data for current position

        const context = this.map.painter.context;
        const gl = context.gl;

        if (!this.texture) {
            this.texture = new Texture(context, this.canvas, gl.RGBA, {premultiply: true});
            this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
        } else if (resize || this._playing) {
            this.texture.update(this.canvas, {premultiply: true});
        }

        let newTilesLoaded = false;
        for (const w in this.tiles) {
            const tile = this.tiles[w];
            if (tile.state !== 'loaded') {
                tile.state = 'loaded';
                tile.texture = this.texture;
                newTilesLoaded = true;
            }
        }

        if (newTilesLoaded) {
            this.fire(new MapSourceDataEvent('data', {sourceDataType: 'idle', sourceId: this.id}));
        }
    }

    serialize(): CanvasSourceSpecification {
        return {
            type: 'canvas',
            animate: this.animate,
            canvas: this.options.canvas,
            coordinates: this.coordinates
        };
    }

    hasTransition(): boolean {
        return this._playing;
    }

    _hasInvalidDimensions(): boolean {
        for (const x of [this.canvas.width, this.canvas.height]) {
            if (isNaN(x) || x <= 0) return true;
        }
        return false;
    }
}
