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

import type GUI from 'lil-gui';
import type { Object3D } from 'three';

import { Color, Plane, PlaneHelper, Vector3, type ColorRepresentation } from 'three';

import type HasDefaultPointOfView from '../core/HasDefaultPointOfView';
import type Instance from '../core/Instance';
import type PointOfView from '../core/PointOfView';
import type Entity3D from '../entities/Entity3D';

import { hasDefaultPointOfView } from '../core/HasDefaultPointOfView';
import * as MemoryUsage from '../core/MemoryUsage';
import Helpers, { hasBoundingVolumeHelper } from '../helpers/Helpers';
import { isMaterial, isVector3 } from '../utils/predicates';
import Panel from './Panel';

const _tempArray: Object3D[] = [];

/**
 * Traverses the object hierarchy exactly once per object,
 * even if the hierarchy is modified during the traversal.
 *
 * In other words, objects can be safely added
 * to the hierarchy without causing infinite recursion.
 *
 * @param callback - The callback to call for each visited object.
 */
function traverseOnce(root: Object3D, callback: (obj: Object3D) => void): void {
    root.traverse((o: Object3D) => _tempArray.push(o));

    while (_tempArray.length > 0) {
        const obj = _tempArray.pop();
        if (obj) {
            callback(obj);
        }
    }
}

class ClippingPlanePanel extends Panel {
    public entity: Entity3D;
    public enableClippingPlane: boolean;
    public normal: Vector3;
    public distance: number;
    public helperSize: number;
    public negate: boolean;
    public planeHelper?: PlaneHelper;

    public constructor(entity: Entity3D, parentGui: GUI, instance: Instance) {
        super(parentGui, instance, 'Clipping plane');

        this.entity = entity;

        this.enableClippingPlane = false;
        this.normal = new Vector3(0, 0, 1);
        this.distance = 0;
        this.helperSize = 5;
        this.negate = false;

        this.addController(this, 'enableClippingPlane')
            .name('Enable')
            .onChange(() => this.updateClippingPlane());

        this.addController(this.normal, 'x')
            .name('Plane normal X')
            .onChange(() => this.updateClippingPlane());
        this.addController(this.normal, 'y')
            .name('Plane normal Y')
            .onChange(() => this.updateClippingPlane());
        this.addController(this.normal, 'z')
            .name('Plane normal Z')
            .onChange(() => this.updateClippingPlane());
        this.addController(this, 'distance')
            .name('Distance')
            .onChange(() => this.updateClippingPlane());
        this.addController(this, 'helperSize')
            .name('Helper size')
            .onChange(() => this.updateClippingPlane());
        this.addController(this, 'negate')
            .name('Negate plane')
            .onChange(() => this.updateClippingPlane());
    }

    public updateClippingPlane(): void {
        this.planeHelper?.removeFromParent();
        this.planeHelper?.dispose();

        if (this.enableClippingPlane) {
            const plane = new Plane(this.normal.clone(), this.distance);
            if (this.negate) {
                plane.negate();
            }
            this.entity.clippingPlanes = [plane];
            this.planeHelper = new PlaneHelper(plane, this.helperSize, 0xff0000);
            this.planeHelper.name = `Clipping plane for ${this.entity.id}`;
            this.instance.scene.add(this.planeHelper);
            this.planeHelper.updateMatrixWorld();
        } else {
            this.entity.clippingPlanes = null;
        }
        this.notify(this.entity);
    }

    public override dispose(): void {
        this.planeHelper?.removeFromParent();
        this.planeHelper?.dispose();
    }
}

interface EntityInspectorOptions {
    /** Display the bounding box checkbox. */
    boundingBoxes?: boolean;
    /** Display the bounding box color checkbox. */
    boundingBoxColor?: boolean;
    /** Display the opacity slider. */
    opacity?: boolean;
    /** Display the visibility checkbox. */
    visibility?: boolean;
}

function getTitle(entity: Entity3D): string {
    if (entity.name != null) {
        return `${entity.name} (${entity.type})`;
    }
    return entity.type;
}

/**
 * Base class for entity inspectors. To implement a custom inspector
 * for an entity type, you can inherit this class.
 */
class EntityInspector<T extends Entity3D = Entity3D> extends Panel {
    /** The inspected entity. */
    public entity: T;
    /** The root object of the entity's hierarchy. */
    public rootObject: Object3D;
    /** Toggle the visibility of the entity. */
    public visible: boolean;
    /** Toggle the visibility of the bounding boxes. */
    public boundingBoxes: boolean;
    public boundingBoxColor: string;
    public state: string;
    public clippingPlanePanel: ClippingPlanePanel;
    public cpuMemoryUsage = 'unknown';
    public gpuMemoryUsage = 'unknown';

    /**
     * @param parentGui - The parent GUI.
     * @param instance - The Giro3D instance.
     * @param entity - The entity to inspect.
     * @param options - The options.
     */
    public constructor(
        parentGui: GUI,
        instance: Instance,
        entity: T,
        options: EntityInspectorOptions = {},
    ) {
        super(parentGui, instance, getTitle(entity));

        this.entity = entity;
        this.rootObject = entity.object3d;
        this.visible = entity.visible;
        this.boundingBoxes = false;
        this.boundingBoxColor = '#FFFF00';
        this.state = 'idle';

        this.addController(this.entity, 'id').name('Identifier');

        this.addController(this, 'cpuMemoryUsage').name('Memory usage (CPU)');
        this.addController(this, 'gpuMemoryUsage').name('Memory usage (GPU)');

        this.addController(this, 'state').name('Status');
        this.addController(this.entity, 'renderOrder')
            .name('Render order')
            .onChange(() => this.notify(this.entity));

        this.clippingPlanePanel = new ClippingPlanePanel(entity, this.gui, instance);

        if (options.visibility === true) {
            this.addController(this, 'visible')
                .name('👁️ Visible')
                .onChange(v => this.toggleVisibility(v));
        }

        this.addController(this.entity, 'frozen')
            .name('❄️ Freeze')
            .onChange(() => this.notify(this.entity));

        if (options.opacity === true) {
            this.addController(this.entity, 'opacity')
                .name('🪟 Opacity')
                .min(0)
                .max(1)
                .onChange(() => this.notify(this.entity));
        }
        if (options.boundingBoxes === true) {
            this.addController(this, 'boundingBoxes')
                .name('🔳 Show volumes')
                .onChange(v => this.toggleBoundingBoxes(v));
            if (options.boundingBoxColor === true) {
                this.addColorController(this, 'boundingBoxColor')
                    .name('Volume color')
                    .onChange(v => this.updateBoundingBoxColor(v));
            }
        }

        if (hasDefaultPointOfView(entity)) {
            this.addController(this, 'goToEntity').name('👣 Go to');
            this.addController(this, 'lookAt').name('🎥 Look at');
        }

        this.addController(this, 'deleteEntity').name('❌ Delete entity');

        this.addController(this.entity.distance, 'min').name('Min view distance').decimals(1);
        this.addController(this.entity.distance, 'max').name('Max view distance').decimals(1);
    }

    private updateControlsWithDefaultView(defaultView: PointOfView | null): void {
        const controls = this.instance.view.controls;
        if (defaultView && controls && 'target' in controls && isVector3(controls.target)) {
            controls.target.copy(defaultView.target);
        }
    }

    public goToEntity(): void {
        const cast = this.entity as unknown as HasDefaultPointOfView;

        const defaultView = this.instance.view.goTo(cast);

        this.updateControlsWithDefaultView(defaultView);

        this.notify();
    }

    public lookAt(): void {
        const cast = this.entity as unknown as HasDefaultPointOfView;

        const defaultView = this.instance.view.goTo(cast, { allowTranslation: false });

        this.updateControlsWithDefaultView(defaultView);

        this.notify();
    }

    public deleteEntity(): void {
        this.instance.remove(this.entity);
    }

    public override dispose(): void {
        this.toggleBoundingBoxes(false);
        this.clippingPlanePanel.dispose();
    }

    public override updateValues(): void {
        const ctx: MemoryUsage.GetMemoryUsageContext = {
            renderer: this.instance.renderer,
            objects: new Map(),
        };
        this.entity.getMemoryUsage(ctx);
        const memUsage = MemoryUsage.aggregateMemoryUsage(ctx);
        this.cpuMemoryUsage = MemoryUsage.format(memUsage.cpuMemory);
        this.gpuMemoryUsage = MemoryUsage.format(memUsage.gpuMemory);
        this.state = this.entity.loading
            ? `loading (${Math.round(this.entity.progress * 100)}%)`
            : 'idle';
        if (this.boundingBoxes) {
            this.toggleBoundingBoxes(true);
        }
    }

    /**
     * Toggles the visibility of the entity in the scene.
     * You may override this method if the entity's visibility is not directly related
     * to its root object visibility.
     *
     * @param visible - The new visibility.
     */
    public toggleVisibility(visible: boolean): void {
        this.entity.visible = visible;
        this.notify(this.entity);
    }

    /**
     * Toggles the visibility of the bounding boxes.
     * You may override this method to use custom bounding boxes.
     *
     * @param visible - The new state.
     */
    public toggleBoundingBoxes(visible: boolean): void {
        const color = new Color(this.boundingBoxColor);
        // by default, adds axis-oriented bounding boxes to each object in the hierarchy.
        // custom implementations may override this to have a different behaviour.
        traverseOnce(this.rootObject, obj => this.addOrRemoveBoundingBox(obj, visible, color));
        this.notify(this.entity);
    }

    /**
     * @param obj - The object to decorate.
     * @param add - If true, bounding box is added, otherwise it is removed.
     * @param color - The bounding box color.
     */

    public addOrRemoveBoundingBox(obj: Object3D, add: boolean, color: Color): void {
        if (add) {
            if ('material' in obj && isMaterial(obj.material)) {
                if (obj.visible && obj.material != null && obj.material.visible) {
                    Helpers.addBoundingBox(obj, color);
                }
            }
        } else {
            Helpers.removeBoundingBox(obj);
        }
    }

    public updateBoundingBoxColor(colorHex: ColorRepresentation): void {
        const color = new Color(colorHex);
        this.rootObject.traverse(obj => {
            if (hasBoundingVolumeHelper(obj)) {
                obj.boundingVolumeHelper.object3d.material.color = color;
            }
        });

        this.notify(this.entity);
    }
}

export default EntityInspector;
