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

// Even if it's not explicited in the changelog
// https://github.com/openlayers/openlayers/blob/main/changelog/upgrade-notes.md
// Around OL6 the replay group mechanism was split into BuilderGroup to create the
// instructions and ExecutorGroup to run them.
// The mechanism was altered following
// https://github.com/openlayers/openlayers/issues/9215
// to make it work
import type BaseEvent from 'ol/events/Event';
import type Feature from 'ol/Feature.js';
import type FeatureFormat from 'ol/format/Feature.js';
import type { Geometry } from 'ol/geom';
import type { Style } from 'ol/style.js';
import type { StyleFunction } from 'ol/style/Style';
import type { Transform } from 'ol/transform.js';

import CanvasBuilderGroup from 'ol/render/canvas/BuilderGroup.js';
import ExecutorGroup from 'ol/render/canvas/ExecutorGroup.js';
import {
    getSquaredTolerance as getSquaredRenderTolerance,
    renderFeature as renderVectorFeature,
} from 'ol/renderer/vector.js';
import Vector from 'ol/source/Vector.js';
import {
    create as createTransform,
    reset as resetTransform,
    scale as scaleTransform,
    translate as translateTransform,
} from 'ol/transform.js';
import { CanvasTexture, Vector2 } from 'three';

import type { GetImageOptions, ImageResponse, ImageSourceOptions } from './ImageSource';

import CoordinateSystem from '../core/geographic/CoordinateSystem';
import Extent from '../core/geographic/Extent';
import EmptyTexture from '../renderer/EmptyTexture';
import Fetcher from '../utils/Fetcher';
import OpenLayersUtils from '../utils/OpenLayersUtils';
import { nonNull } from '../utils/tsutils';
import ImageSource, { ImageResult } from './ImageSource';

const tmpExtent = new Array(4);

const tmpTransform: Transform = createTransform();

function createCanvas(size: Vector2): HTMLCanvasElement {
    const canvas = document.createElement('canvas');
    canvas.width = size.width;
    canvas.height = size.height;
    return canvas;
}

/**
 * Renders a single feature into the builder group.
 *
 * @param feature - The feature to render.
 * @param squaredTolerance - Squared tolerance for geometry simplification.
 * @param styles - The style(s) of the feature.
 * @param builderGroup - The builder group.
 * @param onStyleChanged - A callback when the styles has changed (such as an image being loaded).
 */
function renderFeature(
    feature: Feature,
    squaredTolerance: number,
    styles: Style | Style[],
    builderGroup: CanvasBuilderGroup,
    onStyleChanged: (arg0: BaseEvent) => void,
): void {
    if (styles == null) {
        return;
    }

    function doRender(style: Style): void {
        renderVectorFeature(builderGroup, feature, style, squaredTolerance, onStyleChanged);
    }

    if (Array.isArray(styles)) {
        for (let i = 0, ii = styles.length; i < ii; ++i) {
            doRender(styles[i]);
        }
    } else {
        doRender(styles);
    }
}

/**
 * Rasterizes the builder group into the canvas.
 *
 * @param canvas - The target canvas.
 * @param builderGroup - The builder group.
 * @param extent - The canvas extent.
 * @param size - The canvas size, in pixels.
 */
function rasterizeBuilderGroup(
    canvas: HTMLCanvasElement,
    builderGroup: CanvasBuilderGroup,
    extent: Extent,
    size: Vector2,
): void {
    const pixelRatio = 1;
    const resX = extent.dimensions().x / size.width;
    const resY = extent.dimensions().y / size.height;
    const ctx = canvas.getContext('2d', { willReadFrequently: true });

    if (!ctx) {
        throw new Error('could not acquire 2d context');
    }

    const transform = resetTransform(tmpTransform);
    scaleTransform(transform, pixelRatio / resX, -pixelRatio / resY);
    translateTransform(transform, -extent.minX, -extent.maxY);

    const olExtent = OpenLayersUtils.toOLExtent(extent);
    const resolution = extent.dimensions().x / size.width;

    const executor = new ExecutorGroup(
        olExtent,
        resolution,
        pixelRatio,
        true,
        builderGroup.finish(),
    );

    executor.execute(ctx, [canvas.width, canvas.height], transform, 0, true);
}

/**
 * The data content. Can be:
 *  - The URL to a remote file and a {@link FeatureFormat} to parse the data,
 *  - The content of the source file (such as GeoJSON) and a {@link FeatureFormat} to parse the data,
 *  - A list of OpenLayers {@link Feature} (no format decoder required)
 */
export type DataSource =
    | { url: string; format: FeatureFormat }
    | { content: unknown; format: FeatureFormat }
    | Feature[];

export interface VectorSourceOptions extends ImageSourceOptions {
    /**
     * The projection of the data source. Must be specified if the source
     * does not have the same projection as the Giro3D instance.
     */
    dataProjection?: CoordinateSystem;

    /**
     * The data content.
     */
    data?: DataSource;

    /**
     * The style(s), or style function.
     */
    style?: Style | Style[] | StyleFunction;
}

/**
 * An image source that reads vector data. Internally, this wraps an OpenLayers' [VectorSource](https://openlayers.org/en/latest/apidoc/module-ol_source_Vector-VectorSource.html).
 * This uses OpenLayers' styles and features.
 *
 * Note: to assign a new style to the source, use {@link setStyle} instead of
 * the {@link style} property.
 *
 * @example
 * // To load a remote GeoJSON file
 * const source = new VectorSource(\{
 *      data: 'http://example.com/data.geojson',
 *      format: new GeoJSON(), // Pass the OpenLayers FeatureFormat here
 *      style: new Style(...), // Pass an OpenLayers style here
 * \});
 *
 * // To load a local GeoJSON
 * const source = new VectorSource(\{
 *      data: \{ "type": "FeatureCollection" ... \},
 *      format: new GeoJSON(), // Pass the OpenLayers FeatureFormat here
 *      style: new Style(...), // Pass an OpenLayers style here
 * \});
 *
 * // To load features directly (no need to pass a format as the features are already decoded.)
 * const source = new VectorSource(\{
 *      data: [new Feature(...)], // Pass the OpenLayers features here
 *      style: new Style(...), // Pass an OpenLayers style here
 * \});
 */
class VectorSource extends ImageSource {
    public readonly isVectorSource = true as const;
    public override readonly type = 'VectorSource' as const;

    public readonly data: DataSource;
    public readonly dataProjection: CoordinateSystem | undefined;

    public readonly source: Vector;

    // After initialization
    private _targetProjection: CoordinateSystem | undefined;

    /**
     * The current style.
     * Note: to set a new style, use `setStyle()` instead.
     */
    public style?: Style | Style[] | StyleFunction;

    /**
     * @param options - Options.
     */
    public constructor(options: VectorSourceOptions) {
        super({ ...options, synchronous: true });
        if (options.data == null) {
            throw new Error('"data" parameter is required');
        }

        this.data = options.data;

        this.source = new Vector();

        this.dataProjection = options.dataProjection;
        this.style = options.style;
    }

    /**
     * Change the style of this source. This triggers an update of the source.
     *
     * @param style - The style, or style function.
     */
    public setStyle(style: Style | StyleFunction): void {
        this.style = style;
        this.update();
    }

    private loadFeaturesFromContent(content: unknown, format: FeatureFormat): Feature[] {
        return format.readFeatures(content);
    }

    /**
     * Loads the features from this source, either from:
     * - the URL
     * - the data string (for example a GeoJSON string)
     * - the features array
     */
    public async loadFeatures(): Promise<void> {
        if (Array.isArray(this.data)) {
            this.source.addFeatures(this.data);
        } else if ('url' in this.data) {
            const { url, format } = this.data;
            const content = await Fetcher.text(url.toString(), { priority: this.priority });
            const features = this.loadFeaturesFromContent(content, format);
            this.source.addFeatures(features);
        } else if ('content' in this.data) {
            const { content, format } = this.data;
            const features = this.loadFeaturesFromContent(content, format);
            this.source.addFeatures(features);
        }
    }

    /**
     * Reprojects a feature from the source projection into the target (instance) projection.
     *
     * @param feature - The feature to reproject.
     */
    public reproject(feature: Feature): void {
        feature.getGeometry()?.transform(this.dataProjection?.id, this._targetProjection?.id);
    }

    public override async initialize(opts: { targetProjection: CoordinateSystem }): Promise<void> {
        await this.loadFeatures();

        this._targetProjection = opts.targetProjection;
        const shouldReproject =
            this.dataProjection != null && this.dataProjection !== this._targetProjection;

        if (shouldReproject) {
            for (const feature of this.source.getFeatures()) {
                this.reproject(feature);
            }
        }

        this.source.on('addfeature', evt => {
            const feature = evt.feature;

            if (feature) {
                if (shouldReproject) {
                    this.reproject(feature);
                }

                this.updateFeature(feature);
            }
        });
    }

    public get featureCount(): number {
        return this.getFeatures().length;
    }

    /**
     * Returns an array with the feature in this source.
     *
     * @returns The features.
     */
    public getFeatures(): Feature[] {
        return this.source.getFeatures();
    }

    /**
     * Adds a feature to this source.
     * @param feature - The feature to add.
     */
    public addFeature(feature: Feature): void {
        if (feature != null) {
            this.source.addFeature(feature);
        }
    }

    /**
     * Adds features to this source.
     * @param features - The features to add.
     */
    public addFeatures(features: Feature[]): void {
        if (features != null) {
            this.source.addFeatures(features);
        }
    }

    /**
     * Removes a feature from this source.
     * @param feature - The feature to remove.
     */
    public removeFeature(feature: Feature): void {
        if (feature != null) {
            this.source.removeFeature(feature);
        }
    }

    /**
     * Removes all feature in this source.
     */
    public clear(): void {
        this.source.clear();
        this.update();
    }

    /**
     * Updates the region associated with the feature(s).
     * @param feature - The feature(s) to update.
     */
    public updateFeature(...feature: Feature[]): void {
        if (feature == null || feature.length === 0) {
            return;
        }

        let extent: Extent | null = null;
        const crs = nonNull(this._targetProjection);

        if (feature.length === 1) {
            extent = OpenLayersUtils.getFeatureExtent(feature[0], crs) ?? null;
        } else {
            feature = feature.filter(f => f != null);

            if (feature.length > 0) {
                const extents = feature
                    .map(f => (f != null ? OpenLayersUtils.getFeatureExtent(f, crs) : null))
                    .filter(e => e != null) as Extent[];

                extent = Extent.unionMany(...extents);
            }
        }

        if (extent) {
            this.update(extent);
        }
    }

    /**
     * Returns the feature with the specified id.
     *
     * @param id - The feature id.
     * @returns The feature.
     */
    public getFeatureById(id: string | number): Feature | null {
        return this.source.getFeatureById(id);
    }

    /**
     * Applies the callback for each feature in this source.
     *
     * @param callback - The callback.
     */
    public forEachFeature(callback: (arg0: Feature<Geometry>) => unknown): void {
        this.source.forEachFeature(callback);
    }

    public getCrs(): CoordinateSystem {
        // Note that since we are reprojecting vector _inside_ the source,
        // the source projection is the same as the target projection, indicating
        // that no projection needs to be done on images produced by this source.
        return this._targetProjection ?? CoordinateSystem.unknown;
    }

    public getExtent(): Extent {
        return this.getCurrentExtent() as Extent;
    }

    public getCurrentExtent(): Extent | null {
        const sourceExtent = this.source.getExtent(tmpExtent);
        if (sourceExtent == null || !Number.isFinite(sourceExtent[0])) {
            return null;
        }
        return OpenLayersUtils.fromOLExtent(sourceExtent, nonNull(this._targetProjection));
    }

    /**
     * @param extent - The target extent.
     * @param size - The target pixel size.
     * @returns The builder group, or null if no features have been rendered.
     */
    private createBuilderGroup(extent: Extent, size: Vector2): CanvasBuilderGroup | null {
        const pixelRatio = 1;

        // We collect features in a larger extent than the target, because the feature extent
        // does not take into account the thickness of lines or the size of icons.
        // Thus, icons and lines may appear cropped because they were geographically
        // outside the target extent, but visually within.
        const testExtent = extent.withRelativeMargin(1);

        const resolution = extent.dimensions().x / size.width;
        const olExtent = OpenLayersUtils.toOLExtent(testExtent, 0.001);
        const builderGroup = new CanvasBuilderGroup(0, olExtent, resolution, pixelRatio);
        const squaredTolerance = getSquaredRenderTolerance(resolution, pixelRatio);

        const defaultStyle = this.style;

        let used = false;
        const onStyleChanged = (): void => this.update();
        const render = function render(feature: Feature): void {
            let styles: Style | Style[];
            const style = feature.getStyleFunction() || defaultStyle;
            if (typeof style === 'function') {
                styles = style(feature, resolution) as Style | Style[];
            } else {
                styles = defaultStyle as Style | Style[];
            }
            if (styles != null) {
                renderFeature(feature, squaredTolerance, styles, builderGroup, onStyleChanged);
            }
            used = true;
        };

        this.source.forEachFeatureInExtent(olExtent, render);

        if (used) {
            return builderGroup;
        }
        return null;
    }

    /**
     * @param id - The unique id of the request.
     * @param extent - The request extent.
     * @param size - The size in pixels of the request.
     * @returns The image result.
     */
    private createImage(id: string, extent: Extent, size: Vector2): ImageResult {
        const builderGroup = this.createBuilderGroup(extent, size);
        let texture;
        if (!builderGroup) {
            texture = new EmptyTexture();
        } else {
            const canvas = createCanvas(size);
            rasterizeBuilderGroup(canvas, builderGroup, extent, size);
            texture = new CanvasTexture(canvas);
        }

        return new ImageResult({ id, texture, extent });
    }

    public override intersects(extent: Extent): boolean {
        // It's a bit an issue with vector sources, as they are dynamic : when the user adds
        // a feature, the extent changes. Thus we cannot cache the extent.
        const sourceExtent = this.getCurrentExtent();
        // The extent may be null if no features are present in the source.
        if (sourceExtent) {
            // We need to test against a larger extent because features may be geographically
            // outside the extent, but visual representation may be inside (due to styles not
            // being taken into account when computing the extent of a feature).
            const safetyExtent = extent.withRelativeMargin(1);
            return sourceExtent.intersectsExtent(safetyExtent);
        }

        return false;
    }

    public getImages(options: GetImageOptions): ImageResponse[] {
        const { extent, width, height, id } = options;

        const size = new Vector2(width, height);
        const request = (): ImageResult => this.createImage(id, extent, size);

        return [{ id, request }];
    }
}

export function isVectorSource(obj: unknown): obj is VectorSource {
    return (obj as VectorSource).isVectorSource === true;
}

export default VectorSource;
