// *****************************************************************************
// Copyright (C) 2022 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as DOMPurify from 'dompurify';
import { codicon } from './widget';
import { measureTextHeight, measureTextWidth } from '../browser';

import '../../../src/browser/style/select-component.css';

export interface SelectOption {
    value?: string
    label?: string
    separator?: boolean
    disabled?: boolean
    detail?: string
    description?: string
    markdown?: boolean
    userData?: string
}

export interface SelectComponentProps {
    id?: string
    className?: string
    options: readonly SelectOption[]
    defaultValue?: string | number
    onChange?: (option: SelectOption, index: number) => void,
    onBlur?: () => void,
    onFocus?: () => void,
    alignment?: 'left' | 'right';
}

export interface SelectComponentState {
    dimensions?: DOMRect
    selected: number
    original: number
    hover: number
}

export const SELECT_COMPONENT_CONTAINER = 'select-component-container';

export class SelectComponent extends React.Component<SelectComponentProps, SelectComponentState> {
    protected dropdownElement: HTMLElement;
    protected fieldRef = React.createRef<HTMLDivElement>();
    protected dropdownRef = React.createRef<HTMLDivElement>();
    protected mountedListeners: Map<string, EventListenerOrEventListenerObject> = new Map();
    protected optimalWidth = 0;
    protected optimalHeight = 0;
    protected resizeObserver: ResizeObserver | undefined;
    /** Window the scroll/wheel/resize listeners are attached to; may differ from the main window in secondary windows. */
    protected listenersWindow: Window | undefined;
    /** Perfect-scrollbar ancestors the `ps-scroll-y` listener was attached to. The field may have moved to another window since, so the elements are remembered. */
    protected psScrollListenerTargets: HTMLElement[] = [];

    constructor(props: SelectComponentProps) {
        super(props);
        const selected = this.getInitialSelectedIndex(props);
        this.state = {
            selected,
            original: selected,
            hover: selected
        };

        this.dropdownElement = this.resolveDropdownContainer(document);
    }

    /**
     * The document that currently hosts this component. Falls back to the main document before
     * the first render. Widgets can be moved to secondary windows, so this must be re-queried
     * instead of captured once.
     */
    protected getHostDocument(): Document {
        return this.fieldRef.current?.ownerDocument ?? document;
    }

    /** Finds or creates the dropdown portal container in the given document. */
    protected resolveDropdownContainer(hostDocument: Document): HTMLElement {
        let list = hostDocument.getElementById(SELECT_COMPONENT_CONTAINER);
        if (!list) {
            list = hostDocument.createElement('div');
            list.id = SELECT_COMPONENT_CONTAINER;
            list.className = 'theia-select-component-container';
            hostDocument.body.appendChild(list);
        }
        return list;
    }

    protected getInitialSelectedIndex(props: SelectComponentProps): number {
        let selected = 0;
        if (typeof props.defaultValue === 'number') {
            selected = props.defaultValue;
        } else if (typeof props.defaultValue === 'string') {
            selected = Math.max(props.options.findIndex(e => e.value === props.defaultValue), 0);
        }
        return selected;
    }

    override componentDidUpdate(prevProps: SelectComponentProps, prevState: SelectComponentState): void {
        if (prevState.selected !== this.state.selected && this.state.dimensions) {
            const dropdownContainer = this.dropdownRef?.current;
            const selectedOption = dropdownContainer?.querySelector(`[data-option-index="${this.state.selected}"]`) as HTMLElement | undefined;
            if (dropdownContainer && selectedOption) {
                const optionBottom = selectedOption.offsetTop + selectedOption.offsetHeight;
                const visibleBottom = dropdownContainer.scrollTop + dropdownContainer.clientHeight;
                if (optionBottom > visibleBottom) {
                    dropdownContainer.scrollTop = optionBottom - dropdownContainer.clientHeight;
                } else if (selectedOption.offsetTop < dropdownContainer.scrollTop) {
                    dropdownContainer.scrollTop = selectedOption.offsetTop;
                }
            }
        }
        if (prevProps.defaultValue !== this.props.defaultValue || prevProps.options !== this.props.options) {
            const selected = this.getInitialSelectedIndex(this.props);
            this.setState({
                selected,
                original: selected,
                hover: selected
            });
        }
    }

    get options(): readonly SelectOption[] {
        return this.props.options;
    }

    get value(): string | number | undefined {
        return this.props.options[this.state.selected].value ?? this.state.selected;
    }

    set value(value: string | number | undefined) {
        let index = -1;
        if (typeof value === 'number') {
            index = value;
        } else if (typeof value === 'string') {
            index = this.props.options.findIndex(e => e.value === value);
        }
        if (index >= 0) {
            this.setState({
                selected: index,
                original: index,
                hover: index
            });
        }
    }

    protected get alignLeft(): boolean {
        return this.props.alignment !== 'right';
    }

    protected getOptimalWidth(): number {
        const textWidth = measureTextWidth(this.props.options.map(e => e.label || e.value || '' + (e.detail || '')));
        return Math.ceil(textWidth + 16);
    }

    protected getOptimalHeight(maxWidth?: number): number {
        const firstLine = this.props.options.find(e => e.label || e.value || e.detail);
        if (!firstLine) {
            return 0;
        }
        if (maxWidth) {
            maxWidth = Math.ceil(maxWidth) + 10; // Increase width by 10 due to side padding
        }
        const descriptionHeight = measureTextHeight(this.props.options.map(e => e.description || ''), { maxWidth: `${maxWidth}px` }) + 18;
        const singleLineHeight = measureTextHeight(firstLine.label || firstLine.value || firstLine.detail || '') + 6;
        const optimal = descriptionHeight + singleLineHeight * this.props.options.length;
        return optimal + 20; // Just to be safe, add another 20 pixels here
    }

    protected attachListeners(): void {
        const hide = (event: Event) => {
            if (!this.dropdownRef.current?.contains(event.target as Node)) {
                this.hide();
            }
        };
        const hideOnResize = () => this.hide();
        this.mountedListeners.set('scroll', hide);
        this.mountedListeners.set('wheel', hide);
        this.mountedListeners.set('resize', hideOnResize);

        let parent = this.fieldRef.current?.parentElement;
        while (parent) {
            // Workaround for perfect scrollbar, since using `overflow: hidden`
            // neither triggers the `scroll`, `wheel` nor `blur` event
            if (parent.classList.contains('ps')) {
                parent.addEventListener('ps-scroll-y', hide);
                this.psScrollListenerTargets.push(parent);
            }
            parent = parent.parentElement;
        }

        this.listenersWindow = this.getHostDocument().defaultView ?? window;
        for (const [key, listener] of this.mountedListeners.entries()) {
            this.listenersWindow.addEventListener(key, listener);
        }

        // Catch Lumino sash drags globally - observe the closest lm-Widget panel
        const fieldEl = this.fieldRef.current;
        const resizablePanel = fieldEl?.closest('.lm-Widget') ?? fieldEl?.parentElement;

        if (resizablePanel && typeof ResizeObserver !== 'undefined') {
            let lastWidth = 0;
            let lastHeight = 0;
            let isFirstFire = true;

            this.resizeObserver = new ResizeObserver(entries => {
                for (const entry of entries) {
                    const { width, height } = entry.contentRect;

                    // Ignore the initial automatic fire when the observer attaches
                    if (isFirstFire) {
                        lastWidth = width;
                        lastHeight = height;
                        isFirstFire = false;
                        continue;
                    }

                    // Only hide if the panel dimensions actually changed by more than 2 pixels
                    if (this.state.dimensions && (Math.abs(width - lastWidth) > 2 || Math.abs(height - lastHeight) > 2)) {
                        this.hide();
                    }

                    lastWidth = width;
                    lastHeight = height;
                }
            });
            this.resizeObserver.observe(resizablePanel);
        }
    }

    override componentWillUnmount(): void {
        this.detachListeners();
    }

    protected detachListeners(): void {
        this.resizeObserver?.disconnect();
        this.resizeObserver = undefined;
        if (this.mountedListeners.size > 0) {
            const eventListener = this.mountedListeners.get('scroll')!;
            for (const target of this.psScrollListenerTargets) {
                target.removeEventListener('ps-scroll-y', eventListener);
            }
            for (const [key, listener] of this.mountedListeners.entries()) {
                (this.listenersWindow ?? window).removeEventListener(key, listener);
            }
            this.mountedListeners.clear();
        }
        this.psScrollListenerTargets = [];
        this.listenersWindow = undefined;
    }

    override render(): React.ReactNode {
        const { options } = this.props;
        let { selected } = this.state;
        if (options[selected]?.separator) {
            selected = this.nextNotSeparator('forwards');
        }
        const selectedItemLabel = options[selected]?.label ?? options[selected]?.value;
        // Expose when the currently shown option is disabled so consumers can style the field to match
        // it (e.g. the option previewed while navigating), instead of a fixed committed-value class.
        const selectionDisabled = !!options[selected]?.disabled;
        const fieldClassName = `theia-select-component${this.props.className ? ` ${this.props.className}` : ''}`
            + `${selectionDisabled ? ' theia-select-component-selection-disabled' : ''}`;
        return <>
            <div
                id={this.props.id}
                key="select-component"
                ref={this.fieldRef}
                tabIndex={0}
                className={fieldClassName}
                onClick={e => this.handleClickEvent(e)}
                onBlur={
                    () => {
                        this.hide();
                        this.props.onBlur?.();
                    }
                }
                onFocus={() => this.props.onFocus?.()}
                onKeyDown={e => this.handleKeypress(e)}
            >
                <div key="label" className="theia-select-component-label">{selectedItemLabel}</div>
                <div key="icon" className={`theia-select-component-chevron ${codicon('chevron-down')}`} />
            </div>
            {ReactDOM.createPortal(this.renderDropdown(), this.dropdownElement)}
        </>;
    }

    protected nextNotSeparator(direction: 'forwards' | 'backwards', startFrom?: number): number {
        const { options } = this.props;
        const step = direction === 'forwards' ? 1 : -1;
        const length = this.props.options.length;
        let selected = startFrom ?? this.state.selected;
        let count = 0;
        do {
            selected = (selected + step) % length;
            if (selected < 0) {
                selected = length - 1;
            }
            count++;
        }
        // Skip separators and disabled options so keyboard navigation never lands on a non-selectable entry.
        while ((options[selected]?.separator || options[selected]?.disabled) && count < length);
        return selected;
    }

    protected handleKeypress(ev: React.KeyboardEvent<HTMLDivElement>): void {
        if (!this.fieldRef.current) {
            return;
        }
        if (ev.key === 'ArrowUp') {
            const selected = this.nextNotSeparator('backwards', this.state.hover);
            this.setState({
                selected,
                hover: selected
            });
        } else if (ev.key === 'ArrowDown') {
            if (this.state.dimensions) {
                const selected = this.nextNotSeparator('forwards', this.state.hover);
                this.setState({
                    selected,
                    hover: selected
                });
            } else {
                this.toggleVisibility();
                // Start on the first selectable option (startFrom -1 evaluates index 0 first), so a
                // leading separator or disabled option is skipped instead of becoming the active row.
                const selected = this.nextNotSeparator('forwards', -1);
                this.setState({
                    selected,
                    hover: selected,
                });
            }
        } else if (ev.key === 'Enter') {
            if (!this.state.dimensions) {
                this.toggleVisibility();
            } else {
                const selected = this.state.selected;
                const option = this.props.options[selected];
                if (!option?.disabled) {
                    this.selectOption(selected, option);
                }
            }
        } else if (ev.key === 'Escape' || ev.key === 'Tab') {
            this.hide(undefined, true);
        }
        ev.stopPropagation();
        ev.nativeEvent.stopImmediatePropagation();
    }

    protected handleClickEvent(event: React.MouseEvent<HTMLElement>): void {
        this.toggleVisibility();
        event.stopPropagation();
        event.nativeEvent.stopImmediatePropagation();
    }

    protected toggleVisibility(): void {
        if (!this.fieldRef.current) {
            return;
        }
        if (!this.state.dimensions) {
            // Re-resolve the portal container and listener window against the document that
            // currently hosts the field: the widget may have been moved to a secondary window
            // (or back to the main window) since the last time the dropdown was opened.
            const hostDocument = this.getHostDocument();
            this.dropdownElement = this.resolveDropdownContainer(hostDocument);
            if (this.listenersWindow && this.listenersWindow !== hostDocument.defaultView) {
                this.detachListeners();
            }
            const rect = this.fieldRef.current.getBoundingClientRect();
            this.setState({ dimensions: rect });
        } else {
            this.hide();
        }
    }

    protected hide(index?: number, releaseFocus = false): void {
        const selectedIndex = index === undefined ? this.state.original : index;
        this.setState({
            dimensions: undefined,
            selected: selectedIndex,
            original: selectedIndex,
            hover: selectedIndex
        });
        // Force releasing focus of the select element to allow closing via escape later on
        const hostDocument = this.getHostDocument();
        if (releaseFocus && hostDocument.activeElement && hostDocument.activeElement === this.fieldRef.current) {
            this.fieldRef.current.blur();
        }
    }

    protected renderDropdown(): React.ReactNode {
        if (!this.state.dimensions) {
            return;
        }

        // Secondary windows have no 'theia-app-shell' element; use the document element as the boundary there.
        const hostDocument = this.getHostDocument();
        const shellArea = (hostDocument.getElementById('theia-app-shell') ?? hostDocument.documentElement).getBoundingClientRect();
        const maxWidth = this.alignLeft ? shellArea.width - this.state.dimensions.left : this.state.dimensions.right;
        if (this.mountedListeners.size === 0) {
            // Only attach our listeners once we render our dropdown menu
            this.attachListeners();
            // We can now also calculate the optimal width
            this.optimalWidth = this.getOptimalWidth();
            this.optimalHeight = this.getOptimalHeight(Math.max(this.state.dimensions.width, this.optimalWidth));
        }
        const availableTop = this.state.dimensions.top - shellArea.top;
        const availableBottom = shellArea.top + shellArea.height - this.state.dimensions.bottom;
        // prefer rendering to the bottom unless there is not enough space and more content can be shown to the top
        const invert = availableBottom < this.optimalHeight && (availableBottom - this.optimalHeight) < (availableTop - this.optimalHeight);

        const { options } = this.props;
        const { hover } = this.state;
        const description = options[hover].description;
        const markdown = options[hover].markdown;
        const items = options.map((item, i) => this.renderOption(i, item));
        if (description) {
            let descriptionNode: React.ReactNode | undefined;
            const className = 'theia-select-component-description';
            if (markdown) {
                descriptionNode = <div key="description" className={className}
                    dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(description) }} />; // eslint-disable-line react/no-danger
            } else {
                descriptionNode = <div key="description" className={className}>
                    {description}
                </div>;
            }
            if (invert) {
                items.unshift(descriptionNode);
            } else {
                items.push(descriptionNode);
            }
        }

        return <div key="dropdown" className="theia-select-component-dropdown" style={{
            top: invert ? 'none' : this.state.dimensions.bottom,
            bottom: invert ? shellArea.top - this.state.dimensions.top : 'none',
            left: this.alignLeft ? this.state.dimensions.left : 'none',
            right: this.alignLeft ? 'none' : shellArea.width - this.state.dimensions.right,
            width: Math.min(Math.max(this.state.dimensions.width, this.optimalWidth), maxWidth),
            maxHeight: shellArea.height - (invert ? shellArea.height - this.state.dimensions.bottom : this.state.dimensions.top) - this.state.dimensions.height,
            position: 'absolute'
        }} ref={this.dropdownRef}>
            {items}
        </div>;
    }

    protected renderOption(index: number, option: SelectOption): React.ReactNode {
        if (option.separator) {
            return <div key={index} className="theia-select-component-separator" />;
        }
        const selected = this.state.hover;
        const disabled = !!option.disabled;
        return (
            <div
                key={index}
                data-option-index={index}
                className={`theia-select-component-option${index === selected ? ' selected' : ''}${disabled ? ' disabled' : ''}`}
                onMouseOver={() => {
                    this.setState({
                        hover: index
                    });
                }}
                onMouseDown={() => {
                    if (!disabled) {
                        this.selectOption(index, option);
                    }
                }}
            >
                <div key="value" className="theia-select-component-option-value">{option.label ?? option.value}</div>
                {option.detail && <div key="detail" className="theia-select-component-option-detail">{option.detail}</div>}
            </div>
        );
    }

    protected selectOption(index: number, option: SelectOption): void {
        this.props.onChange?.(option, index);
        this.hide(index);
    }
}
