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

import { Clock, MathUtils, Plane, Sphere, Vector3 } from 'three';

import type Entity from '../entities/Entity';
import type View from '../renderer/View';
import type Context from './Context';
import type Instance from './Instance';

import { isEntity3D } from '../entities/Entity3D';
import { isBufferGeometry } from '../utils/predicates';

/** Rendering state */
export enum RenderingState {
    /* Paused */
    RENDERING_PAUSED = 0,
    /* Scheduled */
    RENDERING_SCHEDULED = 1,
}

class ContextImpl implements Context {
    public readonly view: View;
    public readonly distance: {
        plane: Plane;
        min: number;
        max: number;
    };

    public constructor(view: View) {
        this.view = view;

        this.distance = {
            plane: new Plane().setFromNormalAndCoplanarPoint(
                view.camera.getWorldDirection(new Vector3()),
                view.camera.position,
            ),
            min: Infinity,
            max: 0,
        };
    }
}

const tmpSphere = new Sphere();

function updateElements(context: Context, entity: Entity, elements?: unknown[] | null): void {
    if (!elements) {
        return;
    }
    for (const element of elements) {
        // update element
        const newElementsToUpdate = entity.update(context, element);
        updateElements(context, entity, newElementsToUpdate);
    }
}

class MainLoop {
    private _renderingState: RenderingState;
    public get renderingState(): RenderingState {
        return this._renderingState;
    }
    private _needsRedraw: boolean;
    private _automaticCameraPlaneComputation = true;
    private _updateLoopRestarted: boolean;
    private readonly _changeSources: Set<unknown>;
    private readonly _clock = new Clock();
    private _frame = 0;

    /**
     * The number of frames processed.
     */
    public get frameCount(): number {
        return this._frame;
    }

    /**
     * Toggles automatic camera clipping plane computation.
     * @defaultValue true
     */
    public get automaticCameraPlaneComputation(): boolean {
        return this._automaticCameraPlaneComputation;
    }

    public set automaticCameraPlaneComputation(v: boolean) {
        this._automaticCameraPlaneComputation = v;
    }

    public constructor() {
        this._renderingState = RenderingState.RENDERING_PAUSED;
        this._needsRedraw = false;
        this._updateLoopRestarted = true;
        this._changeSources = new Set<unknown>();
    }

    public scheduleUpdate(
        instance: Instance,
        changeSource: unknown | unknown[] = undefined,
        options?: {
            needsRedraw?: boolean;
            immediate?: boolean;
        },
    ): void {
        if (changeSource != null) {
            if (Array.isArray(changeSource)) {
                changeSource.forEach(s => this._changeSources.add(s));
            } else {
                this._changeSources.add(changeSource);
            }
        }
        const needsRedraw = options?.needsRedraw ?? true;
        const immediate = options?.immediate ?? false;
        this._needsRedraw = this._needsRedraw || needsRedraw;

        if (this._renderingState !== RenderingState.RENDERING_SCHEDULED) {
            this._renderingState = RenderingState.RENDERING_SCHEDULED;

            if (immediate) {
                this.step(instance);
            } else {
                requestAnimationFrame(() => {
                    this.step(instance);
                });
            }
        }
    }

    private update(instance: Instance, updateSources: Set<unknown>, dt: number): void {
        const frame = this._frame;

        const context = new ContextImpl(instance.view);

        for (const entity of instance.getEntities()) {
            if (entity.shouldCheckForUpdate()) {
                instance.dispatchEvent({
                    type: 'before-entity-update',
                    frame,
                    entity,
                    dt,
                    updateLoopRestarted: this._updateLoopRestarted,
                });

                // Filter updateSources that are relevant for the entity
                const srcs = entity.filterChangeSources(updateSources);
                if (srcs.size > 0) {
                    // `preUpdate` returns an array of elements to update
                    const elementsToUpdate = entity.preUpdate(context, srcs);
                    // `update` is called in `updateElements`.
                    updateElements(context, entity, elementsToUpdate);
                    // `postUpdate` is called when this geom layer update process is finished
                    entity.postUpdate(context, updateSources);
                }

                if (isEntity3D(entity)) {
                    const entityDistance = entity.distance;
                    context.distance.min = Math.min(context.distance.min, entityDistance.min);
                    if (entityDistance.max === Infinity) {
                        context.distance.max = instance.view.maxFarPlane;
                    } else {
                        context.distance.max = Math.max(context.distance.max, entityDistance.max);
                    }
                }

                instance.dispatchEvent({
                    type: 'after-entity-update',
                    frame,
                    entity,
                    dt,
                    updateLoopRestarted: this._updateLoopRestarted,
                });
            }
        }

        // TODO document the fact Object3D must be added through threeObjects
        // if they want to influence the near / far planes
        this.updateCameraPlanesFromObjects(context, instance);

        if (this.automaticCameraPlaneComputation) {
            instance.view.near = Number.isFinite(context.distance.min)
                ? context.distance.min
                : instance.view.near;
            instance.view.far = Number.isFinite(context.distance.max)
                ? context.distance.max
                : instance.view.far;

            instance.view.camera.updateProjectionMatrix();
        }
    }

    private updateCameraPlanesFromObjects(context: Context, instance: Instance): void {
        instance.threeObjects.traverse(o => {
            if (!o.visible) {
                return;
            }

            if ('geometry' in o && isBufferGeometry(o.geometry)) {
                const boundingSphere = o.geometry.boundingSphere;

                if (boundingSphere && !boundingSphere.isEmpty()) {
                    tmpSphere.copy(boundingSphere);
                    tmpSphere.applyMatrix4(o.matrixWorld);

                    const d = tmpSphere.distanceToPoint(context.view.camera.position);

                    context.distance.min = MathUtils.clamp(d, 0.01, context.distance.min);
                    context.distance.max = Math.max(context.distance.max, d + 2 * tmpSphere.radius);
                }
            }
        });
    }

    private step(instance: Instance): void {
        const dt = this._clock.getDelta() * 1000;

        const frame = this._frame++;

        instance.dispatchEvent({
            type: 'update-start',
            frame,
            dt,
            updateLoopRestarted: this._updateLoopRestarted,
        });

        const willRedraw = this._needsRedraw;

        // Reset internal state before calling _update (so future calls to Instance.notifyChange()
        // can properly change it)
        this._needsRedraw = false;
        this._renderingState = RenderingState.RENDERING_PAUSED;
        const updateSources = new Set(this._changeSources);
        this._changeSources.clear();

        instance.dispatchEvent({
            type: 'before-camera-update',
            frame,
            camera: instance.view,
            dt,
            updateLoopRestarted: this._updateLoopRestarted,
        });

        if (this.automaticCameraPlaneComputation) {
            // Reset near/far to default value to allow update function to test
            // visibility using camera's frustum; without depending on the near/far
            // values which are only used for rendering.
            instance.view.resetPlanes();
        }

        const dim = instance.engine.getWindowSize();
        instance.view.setSize(dim.x, dim.y);
        instance.view.update();

        instance.dispatchEvent({
            type: 'after-camera-update',
            frame,
            camera: instance.view,
            dt,
            updateLoopRestarted: this._updateLoopRestarted,
        });

        // Disable camera's matrix auto update to make sure the camera's
        // world matrix is never updated mid-update.
        // Otherwise inconsistencies can appear because object visibility
        // testing and object drawing could be performed using different
        // camera matrixWorld.
        // Note: this is required at least because WEBGLRenderer calls
        // camera.updateMatrixWorld()
        const oldAutoUpdate = instance.view.camera.matrixAutoUpdate;
        instance.view.camera.matrixAutoUpdate = false;

        // update data-structure
        this.update(instance, updateSources, dt);

        // Redraw *only* if needed.
        // (redraws only happen when this.needsRedraw is true, which in turn only happens when
        // instance.notifyChange() is called with redraw=true)
        // As such there's no continuous update-loop, instead we use a ad-hoc update/render
        // mechanism.
        if (willRedraw) {
            instance.dispatchEvent({
                type: 'before-render',
                frame,
                dt,
                updateLoopRestarted: this._updateLoopRestarted,
            });
            instance.render();
            instance.dispatchEvent({
                type: 'after-render',
                frame,
                dt,
                updateLoopRestarted: this._updateLoopRestarted,
            });
        }

        // next time, we'll consider that we've just started the loop if we are still PAUSED now
        this._updateLoopRestarted = this._renderingState === RenderingState.RENDERING_PAUSED;

        instance.view.camera.matrixAutoUpdate = oldAutoUpdate;

        instance.dispatchEvent({
            type: 'update-end',
            frame,
            dt,
            updateLoopRestarted: this._updateLoopRestarted,
        });
    }
}

export default MainLoop;
