// *****************************************************************************
// Copyright (C) 2022 Ericsson 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 { inject, injectable } from 'inversify';
import { Disposable, DisposableCollection, disposableTimeout, isOSX, PreferenceService } from '../common';
import { MarkdownString } from '../common/markdown-rendering/markdown-string';
import { animationFrame } from './browser';
import { wireMarkdownLinkHandler } from './markdown-rendering/markdown-link-handler';
import { CoreMarkdownRenderer, MarkdownRenderer } from './markdown-rendering/markdown-renderer';
import { OpenerService } from './opener-service';

import '../../src/browser/style/hover-service.css';

export type HoverPosition = 'left' | 'right' | 'top' | 'bottom';

// Threshold, in milliseconds, over which a mouse movement is not considered
// quick enough as to be ignored
const quickMouseThresholdMillis = 200;

export namespace HoverPosition {
    export function invertIfNecessary(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): HoverPosition {
        if (position === 'left') {
            if (target.left - host.width - 5 < 0) {
                return 'right';
            }
        } else if (position === 'right') {
            if (target.right + host.width + 5 > totalWidth) {
                return 'left';
            }
        } else if (position === 'top') {
            if (target.top - host.height - 5 < 0) {
                return 'bottom';
            }
        } else if (position === 'bottom') {
            if (target.bottom + host.height + 5 > totalHeight) {
                return 'top';
            }
        }
        return position;
    }
}

export interface HoverRequest {
    content: string | MarkdownString | HTMLElement
    target: HTMLElement
    /**
     * The position where the hover should appear.
     * Note that the hover service will try to invert the position (i.e. right -> left)
     * if the specified content does not fit in the window next to the target element
     */
    position: HoverPosition
    /**
     * Additional css classes that should be added to the hover box.
     * Used to style certain boxes different e.g. for the extended tab preview.
     */
    cssClasses?: string[]
    /**
     * A function to render a visual preview on the hover.
     * Function that takes the desired width and returns a HTMLElement to be rendered.
     */
    visualPreview?: (width: number) => HTMLElement | undefined;
    /**
     * Indicates if the hover contains interactive/clickable items.
     * When true, the hover will register a click handler to allow interaction with elements in the hover area.
     */
    interactive?: boolean;
    /**
     * If implemented, this method will be called when the hover is no longer shown or no longer scheduled to be shown.
     */
    onHide?(): void;
    /**
     * When true, the hover will be shown immediately without any delay.
     * Useful for explicitly triggered hovers (e.g., on click) where the user expects instant feedback.
     * @default false
     */
    skipHoverDelay?: boolean;
}

@injectable()
export class HoverService {
    protected static hostClassName = 'theia-hover';
    protected static styleSheetId = 'theia-hover-style';
    @inject(PreferenceService) protected readonly preferences: PreferenceService;

    // Inject CoreMarkdownRenderer rather than the MarkdownRenderer symbol,
    // which is rebound by Monaco to a renderer that strips code block content.
    @inject(CoreMarkdownRenderer) protected readonly markdownRenderer: MarkdownRenderer;

    @inject(OpenerService) protected readonly openerService: OpenerService;

    protected _hoverHost: HTMLElement | undefined;
    protected get hoverHost(): HTMLElement {
        if (!this._hoverHost) {
            this._hoverHost = document.createElement('div');
            this._hoverHost.classList.add(HoverService.hostClassName);
            this._hoverHost.style.position = 'absolute';
            this._hoverHost.setAttribute('popover', 'hint');
        }
        return this._hoverHost;
    }
    protected pendingTimeout: Disposable | undefined;
    protected hoverTarget: HTMLElement | undefined;
    protected lastHidHover = Date.now();
    protected readonly disposeOnHide = new DisposableCollection();

    requestHover(request: HoverRequest): void {
        this.cancelHover();
        const delay = request.skipHoverDelay ? 0 : this.getHoverDelay();
        this.pendingTimeout = disposableTimeout(() => this.renderHover(request), delay);
        this.hoverTarget = request.target;
        this.listenForMouseOut();
        this.listenForMouseClick(request);
    }

    protected getHoverDelay(): number {
        return Date.now() - this.lastHidHover < quickMouseThresholdMillis
            ? 0
            : this.preferences.get('workbench.hover.delay', isOSX ? 1500 : 500);
    }

    protected async renderHover(request: HoverRequest): Promise<void> {
        const host = this.hoverHost;
        let firstChild: HTMLElement | undefined;
        const { target, content, position, cssClasses, interactive, onHide } = request;
        if (onHide) {
            this.disposeOnHide.push({ dispose: onHide.bind(request) });
        }
        if (cssClasses) {
            host.classList.add(...cssClasses);
        }
        if (content instanceof HTMLElement) {
            host.appendChild(content);
            firstChild = content;
        } else if (typeof content === 'string') {
            host.textContent = content;
        } else {
            const renderedContent = this.markdownRenderer.render(content);
            this.disposeOnHide.push(renderedContent);
            host.appendChild(renderedContent.element);
            firstChild = renderedContent.element;
            this.disposeOnHide.push(wireMarkdownLinkHandler(renderedContent.element, content, this.openerService));
        }
        // browsers might insert linebreaks when the hover appears at the edge of the window
        // resetting the position prevents that
        host.style.left = '0px';
        host.style.top = '0px';
        document.body.append(host);
        if (!host.matches(':popover-open')) {
            host.showPopover();
        }

        if (interactive) {
            // Add a click handler to the hover host to ensure clicks within the hover area work properly
            const clickHandler = (e: MouseEvent) => {
                // Let click events within the hover area be processed by their handlers
                // but prevent them from triggering document handlers that might dismiss the tooltip
                e.stopImmediatePropagation();
            };
            host.addEventListener('click', clickHandler);
            this.disposeOnHide.push({ dispose: () => host.removeEventListener('click', clickHandler) });
        }

        if (request.visualPreview) {
            // If just a string is being rendered use the size of the outer box
            const width = firstChild ? firstChild.offsetWidth : this.hoverHost.offsetWidth;
            const visualPreview = request.visualPreview(width);
            if (visualPreview) {
                host.appendChild(visualPreview);
            }
        }

        await animationFrame(); // Allow the browser to size the host
        const updatedPosition = this.setHostPosition(target, host, position);

        this.disposeOnHide.push({
            dispose: () => {
                this.lastHidHover = Date.now();
                host.classList.remove(updatedPosition);
                if (cssClasses) {
                    host.classList.remove(...cssClasses);
                }
            }
        });
    }

    protected setHostPosition(target: HTMLElement, host: HTMLElement, position: HoverPosition): HoverPosition {
        const targetDimensions = target.getBoundingClientRect();
        const hostDimensions = host.getBoundingClientRect();
        const documentWidth = document.body.getBoundingClientRect().width;
        // document.body.getBoundingClientRect().height doesn't work as expected
        // scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777
        const documentHeight = document.documentElement.scrollHeight;
        position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight);
        if (position === 'top' || position === 'bottom') {
            const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2);
            const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2);
            const furthestRight = Math.min(documentWidth - hostDimensions.width, middleAlignment);
            const left = Math.max(0, furthestRight);
            const top = position === 'top'
                ? targetDimensions.top - hostDimensions.height - 5
                : targetDimensions.bottom + 5;
            host.style.setProperty('--theia-hover-before-position', `${targetMiddleWidth - left - 5}px`);
            host.style.top = `${top}px`;
            host.style.left = `${left}px`;
        } else {
            const targetMiddleHeight = targetDimensions.top + (targetDimensions.height / 2);
            const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2);
            const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment);
            const top = Math.max(0, furthestTop);
            const left = position === 'left'
                ? targetDimensions.left - hostDimensions.width - 5
                : targetDimensions.right + 5;
            host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`);
            host.style.left = `${left}px`;
            host.style.top = `${top}px`;
        }
        host.classList.add(position);
        return position;
    }

    protected listenForMouseOut(): void {
        const handleMouseLeave = (e: MouseEvent) => {
            this.disposeOnHide.push(disposableTimeout(() => {
                if (!this.hoverHost.matches(':hover') && !this.hoverTarget?.matches(':hover')) {
                    this.cancelHover();
                }
            }, quickMouseThresholdMillis));
        };
        this.hoverTarget?.addEventListener('mouseout', handleMouseLeave);
        this.hoverHost.addEventListener('mouseout', handleMouseLeave);
        this.disposeOnHide.push({
            dispose: () => {
                this.hoverTarget?.removeEventListener('mouseout', handleMouseLeave);
                this.hoverHost.removeEventListener('mouseout', handleMouseLeave);
            }
        });
    }

    cancelHover(): void {
        this.pendingTimeout?.dispose();
        this.unRenderHover();
        this.disposeOnHide.dispose();
        this.hoverTarget = undefined;
    }

    /**
     * Listen for mouse click (mousedown) events and handle them based on hover interactivity.
     * For non-interactive hovers, any mousedown cancels the hover immediately.
     * For interactive hovers, the hover remains visible to allow interaction with its elements.
     */
    protected listenForMouseClick(request: HoverRequest): void {
        const handleMouseDown = (e: MouseEvent) => {
            if (this.hoverHost.contains(e.target as Node)) {
                return;
            }
            if (!request.interactive) {
                this.cancelHover();
            }
        };
        document.addEventListener('mousedown', handleMouseDown, true);
        this.disposeOnHide.push({ dispose: () => document.removeEventListener('mousedown', handleMouseDown, true) });
    }

    protected unRenderHover(): void {
        if (this.hoverHost.matches(':popover-open')) {
            this.hoverHost.hidePopover();
        }
        this.hoverHost.remove();
        this.hoverHost.replaceChildren();
    }
}
