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

import type GUI from 'lil-gui';
import type { BufferGeometry, Material, Mesh, Object3D, Scene } from 'three';

import { Color } from 'three';

import type Instance from '../../core/Instance';
import type Entity3D from '../../entities/Entity3D';
import type { BoundingBoxHelper } from '../../helpers/Helpers';

import Helpers from '../../helpers/Helpers';
import { isLight } from '../../utils/predicates';
import Panel from '../Panel';
import OutlinerPropertyView from './OutlinerPropertyView';

interface OutlinedObject3D extends Object3D {
    // We use underscores to avoid potential naming conflicts with existing properties
    ___outlinerTreeviewVisible?: boolean;
    ___outlinerTreeviewCollapsed?: boolean;
}
type ClickHandler = (obj: OutlinedObject3D) => void;
interface Filter {
    showHelpers: boolean;
    showHiddenObjects: boolean;
    searchRegex?: RegExp;
    searchQuery: string;
}

interface TreeviewNode {
    object: OutlinedObject3D;
    root: HTMLElement;
    collapseButton: HTMLElement;
    name: HTMLParagraphElement;
    textColor: string;
    opacity?: string;
}

function getHash(scene: Scene): number {
    let hash = 27 | 0;

    scene.traverse(obj => {
        hash = (13 * hash + obj.id) | 0;
    });

    return hash;
}

/**
 * Returns the colors associated with the THREE object type.
 *
 * @param obj - the THREE object
 * @returns the object containing foreground and background colors
 */
function selectColor(obj: OutlinedObject3D): { back: string; fore: string } {
    const entity = isEntityRoot(obj);
    if (entity) {
        return { back: 'gold', fore: 'black' };
    }
    if (isLight(obj)) {
        return { back: 'yellow', fore: 'black' };
    }
    switch (obj.type) {
        case 'Mesh':
        case 'TileMesh':
            return { back: 'orange', fore: 'black' };
        case 'Points':
            return { back: 'red', fore: 'white' };
        case 'Object3D':
            return { back: 'gray', fore: 'white' };
        case 'Scene':
            return { back: '#CCCCCC', fore: 'black' };
        case 'Group':
            return { back: 'green', fore: 'white' };
        default:
            return { back: 'blue', fore: 'white' };
    }
}

function isMesh(obj: Object3D): obj is Mesh {
    return (obj as Mesh).isMesh;
}

function hasSingleMaterial(mesh: Mesh): mesh is Mesh<BufferGeometry, Material> {
    return !Array.isArray(mesh.material);
}

function getMaterialVisibility(obj: Object3D): boolean {
    if (isMesh(obj) && hasSingleMaterial(obj)) {
        return obj.material.visible;
    }

    return true;
}

function isEntityRoot(obj: Object3D): Entity3D | null {
    if (obj.userData.parentEntity != null) {
        const entity: Entity3D = obj.userData.parentEntity;
        if (entity.object3d === obj) {
            return entity;
        }
    }

    return null;
}

function getType(obj: Object3D): string {
    const entity = isEntityRoot(obj);
    if (entity != null) {
        return entity.type;
    }
    return obj.type;
}

function getName(obj: Object3D): string {
    const entity = isEntityRoot(obj);
    if (entity != null) {
        return entity.name ?? entity.id;
    }
    return obj.name;
}

function createTreeViewNode(
    object: OutlinedObject3D,
    marginLeft: number,
    clickHandler: ClickHandler,
    onUpdate: () => void,
): TreeviewNode {
    const root = document.createElement('button');
    root.style.width = 'unset';
    root.style.textAlign = 'left';
    root.onclick = (): void => clickHandler(object);

    const collapseButton = document.createElement('button');
    collapseButton.style.width = '1rem';
    collapseButton.style.height = '1rem';
    collapseButton.title = 'collapse sub-tree';
    collapseButton.style.backgroundColor = 'grey';
    collapseButton.style.margin = '2px';
    collapseButton.style.borderRadius = '3px';
    collapseButton.innerText = object.___outlinerTreeviewCollapsed === true ? '➕' : '➖';
    collapseButton.onclick = function onclick(): void {
        if (object.___outlinerTreeviewCollapsed == null) {
            object.___outlinerTreeviewCollapsed = false;
        }
        object.___outlinerTreeviewCollapsed = !object.___outlinerTreeviewCollapsed;
        collapseButton.innerText = object.___outlinerTreeviewCollapsed ? '➕' : '➖';
        onUpdate();
    };

    const name = document.createElement('p');
    name.style.marginLeft = `${marginLeft}px`;
    name.style.marginTop = '0px';
    name.style.marginBottom = '0px';
    name.style.background = 'transparent';
    const textColor = getMaterialVisibility(object) ? 'white' : 'rgba(222, 208, 105, 0.59)';
    const { fore, back } = selectColor(object);
    name.innerHTML = `<span style="border-radius: 6px; padding: 2px; font-family: monospace; background-color: ${back}; color: ${fore}">${getType(object)}</span> <span style="font-family: monospace; color: ${textColor}";>${getName(object)}</span>`;

    root.appendChild(name);

    return { root, collapseButton, name, object, textColor, opacity: undefined };
}

function updateNode(node: TreeviewNode): void {
    const { root, object, name } = node;

    const opacity = object.visible ? '100%' : '50%';

    if (node.opacity == null || node.opacity !== opacity) {
        root.style.opacity = opacity;
        node.opacity = opacity;
    }

    const textColor = getMaterialVisibility(object) ? 'white' : 'rgba(222, 208, 105, 0.59)';
    const { fore, back } = selectColor(object);

    if (textColor !== node.textColor) {
        node.textColor = textColor;
        name.innerHTML = `<span style="border-radius: 6px; padding: 2px; font-family: monospace; background-color: ${back}; color: ${fore}">${object.type}</span> <span style="font-family: monospace; color: ${textColor}";>${object.name}</span>`;
    }
}

/**
 * Creates a treeview node for the specified object and its children.
 *
 * @param obj - the THREE object.
 * @param clickHandler - the function to call when a node is clicked.
 * @param level - the hierarchy level
 */
function createTreeViewNodeWithDescendants(
    obj: OutlinedObject3D,
    clickHandler: ClickHandler,
    onUpdate: () => void,
    map: Map<number, TreeviewNode>,
    level = 0,
): HTMLDivElement | undefined {
    if (obj.type !== 'Scene' && obj.___outlinerTreeviewVisible === false) {
        return undefined;
    }

    const div = document.createElement('div');
    div.style.background = 'transparent';
    div.style.opacity = obj.visible ? '100%' : '50%';

    // create the DOM element for the object itself
    const marginLeft = level * 15;
    const node = createTreeViewNode(obj, marginLeft, clickHandler, onUpdate);
    map.set(obj.id, node);
    div.appendChild(node.collapseButton);
    div.appendChild(node.root);

    if (obj.___outlinerTreeviewCollapsed === undefined) {
        obj.___outlinerTreeviewCollapsed = false;
    }

    if (obj.___outlinerTreeviewCollapsed !== true) {
        // recursively create the DOM elements for the children
        const childLevel = level + 1;
        obj.children.forEach((child: OutlinedObject3D) => {
            const childNode = createTreeViewNodeWithDescendants(
                child,
                clickHandler,
                onUpdate,
                map,
                childLevel,
            );
            if (childNode) {
                div.appendChild(childNode);
            }
        });
    }

    return div;
}

function setAncestorsVisible(obj: OutlinedObject3D): void {
    if (obj != null) {
        obj.___outlinerTreeviewVisible = true;
        setAncestorsVisible(obj.parent as OutlinedObject3D);
    }
}

function isHelper(obj: Object3D): boolean {
    return 'isHelper' in obj && obj.isHelper === true;
}

function matches(obj: Object3D, regex?: RegExp): boolean {
    if (regex == null) {
        return true;
    }

    if (regex.test(obj.name.toLowerCase())) {
        return true;
    }

    if (regex.test(obj.type.toLowerCase())) {
        return true;
    }

    return false;
}

function shouldBeDisplayedInTree(obj: OutlinedObject3D, filter: Filter): boolean {
    if (isHelper(obj) && !filter.showHelpers) {
        return false;
    }

    if (!obj.visible && !filter.showHiddenObjects) {
        return false;
    }

    if (matches(obj, filter.searchRegex)) {
        return true;
    }

    return false;
}

/**
 * @param obj - the object to process
 * @param filter - the search filter
 */
function applySearchFilter(obj: OutlinedObject3D, filter: Filter): void {
    if (shouldBeDisplayedInTree(obj, filter)) {
        setAncestorsVisible(obj);
    } else {
        obj.___outlinerTreeviewVisible = false;
    }

    if (obj.children != null) {
        obj.children.forEach((c: OutlinedObject3D) => applySearchFilter(c, filter));
    }
}

/**
 * Provides a tree view of the three.js [scene](https://threejs.org/docs/index.html?q=scene#api/en/scenes/Scene).
 *
 */
class Outliner extends Panel {
    public filters: Filter;
    public treeviewContainer: HTMLDivElement;
    public treeview: HTMLDivElement;
    public rootNode: HTMLDivElement | undefined;
    public propView: OutlinerPropertyView;
    public selectionHelper?: BoundingBoxHelper;
    public sceneHash: number | undefined = undefined;
    private readonly _nodes: Map<number, TreeviewNode> = new Map();

    /**
     * @param gui - The GUI.
     * @param instance - The Giro3D instance.
     */
    public constructor(gui: GUI, instance: Instance) {
        super(gui, instance, 'Outliner');

        this.filters = {
            showHelpers: false,
            showHiddenObjects: true,
            searchQuery: '',
            searchRegex: undefined,
        };

        this.treeviewContainer = document.createElement('div');

        this.treeview = document.createElement('div');
        this.treeview.style.background = '#424242';
        this.treeview.id = 'treeview';
        this.treeview.style.height = '350px';
        this.treeview.style.overflow = 'auto';
        // avoid wrapping ids and coordinates for deep-nested elements
        this.treeview.style.whiteSpace = 'nowrap';

        this.addController(this.filters, 'showHelpers')
            .name('Show helpers')
            .onChange(() => {
                this.search();
                this.instance.notifyChange();
            });
        this.addController(this.filters, 'showHiddenObjects')
            .name('Show hidden objects')
            .onChange(() => {
                this.search();
                this.instance.notifyChange();
            });
        this.addController(this.filters, 'searchQuery')
            .name('Name filter')
            .onChange(() => {
                this.search();
                this.instance.notifyChange();
            });
        this.treeviewContainer.appendChild(this.treeview);

        // A little bit of DOM hacking to insert the treeview in the GUI.
        const treeGui = this.gui.addFolder('Hierarchy');
        const children = treeGui.domElement.getElementsByClassName('children');
        children[0].appendChild(this.treeviewContainer);

        this.updateTreeView();

        this.propView = new OutlinerPropertyView(this.gui, this.instance);
    }

    public override updateValues(): void {
        this.updateTreeView();
    }

    public onNodeClicked(obj: OutlinedObject3D): void {
        this.select(obj);
        this.propView.populateProperties(obj);
        this.instance.notifyChange();
    }

    /**
     * Selects the object by displaying a bright bounding box around it.
     *
     * @param obj - The object to select.
     */
    public select(obj: OutlinedObject3D): void {
        this.clearSelection();

        if ((obj as unknown) === this.selectionHelper) {
            return;
        }

        this.selectionHelper = Helpers.createSelectionBox(obj, new Color('#00FF00'));
        this.selectionHelper.name = 'selection';
    }

    /**
     * Unselect the currently selected object.
     */
    public clearSelection(): void {
        if (this.selectionHelper && this.selectionHelper.parent) {
            this.selectionHelper.parent.remove(this.selectionHelper);
        }
        delete this.selectionHelper;
    }

    public search(): void {
        this.filters.searchQuery = this.filters.searchQuery.trim().toLowerCase();
        this.filters.searchRegex =
            this.filters.searchQuery.length > 0 ? new RegExp(this.filters.searchQuery) : undefined;
        this.sceneHash = undefined;
        this.updateTreeView();
    }

    public updateObject(o: Object3D): void {
        o.updateMatrixWorld(true);
        this.instance.notifyChange();
    }

    private updateExistingNodes(): void {
        this._nodes.forEach(n => updateNode(n));
    }

    public updateTreeView(): void {
        if (this.isClosed()) {
            // we don't want to refresh the treeview if the GUI is collapsed.
            return;
        }

        const hash = getHash(this.instance.scene);
        if (hash === this.sceneHash) {
            this.updateExistingNodes();
        } else {
            this.sceneHash = hash;

            if (this.rootNode) {
                this.treeview.removeChild(this.rootNode);
            }

            applySearchFilter(this.instance.scene as unknown as OutlinedObject3D, this.filters);

            this._nodes.clear();

            const onUpdate = (): void =>
                queueMicrotask(() => {
                    this.sceneHash = undefined;
                    this.updateTreeView();
                });

            this.rootNode = createTreeViewNodeWithDescendants(
                this.instance.scene as unknown as OutlinedObject3D,
                obj => this.onNodeClicked(obj),
                onUpdate,
                this._nodes,
            );
            if (this.rootNode) {
                this.treeview.appendChild(this.rootNode);
            }
        }
    }
}

export default Outliner;
