/* eslint-disable ts/no-this-alias */
/* eslint-disable no-useless-call */

import type { VNode } from 'vue';
import type { ModalOnBeforeShownArgs, ModalOnShownArgs } from './modal-utils';
import { Modal as BootstrapModal } from 'bootstrap';
import { Prop, toNative } from 'vue-facing-decorator';
import PowerduckState from '../../app/powerduck-state';
import TsxComponent, { Component } from '../../app/vuetsx';
import DropdownUtils from '../../common/utils/dropdown-utils';
import { isNullOrEmpty } from '../../common/utils/is-null-or-empty';
import { PortalUtils } from '../../common/utils/utils';
import LoadingIndicator from '../loading-indicator';
import Teleport from '../teleport/teleport';
import ModalAnimationError from './animation-error';
import ModalAnimationSuccess from './animation-success';
import ModalIconWarning from './icon-warning';
import { ModalConfig, ModalMobileMode } from './modal-config';
import { ModalUtils } from './modal-utils';
import './css/modal.css';

interface ModalArgs {
	title: string | VNode;
	backdropCssClass?: string;
	size?: ModalSize;
	icon?: ModalHeaderIcon;
	blocked?: boolean;
	cssClass?: string;
	mobileMode?: ModalMobileMode;
	dismissable?: boolean;
	backdropStatic?: boolean;
	modalLazyMode?: boolean;
	preventHistoryEntry?: boolean;
	portalTarget?: string;
	ariaRole?: 'dialog' | 'alertdialog';
}

export enum ModalSize {
	Normal = 0,
	Small = 1,
	Large = 2,
	ExtraLarge = 3,
	FullWidth = 4,
	NormalToLarge = 5,
	SmallToNormal = 6,
}

export enum ModalHeaderIcon {
	Success = 0,
	Error = 1,
	Warning = 2,
	Question = 3,
}

declare global {
	export interface ModalShowArgs {
		onHidden?: () => void;
		onShown?: (e: ModalOnShownArgs) => void;
		onBeforeShown?: (e: ModalOnBeforeShownArgs) => void;
	}
}

export { ModalConfig, ModalMobileMode } from './modal-config';

// Module-scoped stack of currently open modal IDs. Top of stack handles ESC.
// See QA__G-159: closes modals on ESC without activating Bootstrap's _enforceFocus(),
// which would break Select2 / portal-rendered widgets inside admin modals.
const openModalsStack: string[] = [];

@Component
class ModalComponent extends TsxComponent<ModalArgs> implements ModalArgs {
	@Prop() title!: string | VNode;
	@Prop() backdropCssClass!: string;
	@Prop() size!: ModalSize;
	@Prop() icon!: ModalHeaderIcon;
	@Prop() blocked!: boolean;
	@Prop() cssClass!: string;
	@Prop() dismissable: boolean;
	@Prop() backdropStatic!: boolean;
	@Prop() mobileMode?: ModalMobileMode;
	@Prop() modalLazyMode: boolean;
	@Prop() preventHistoryEntry: boolean;
	@Prop() ariaRole?: 'dialog' | 'alertdialog';
	uuid: string = null;
	modalShown: boolean = false;
	/**
	 * Mobile presentation css pinned at show() time. The presentation decision
	 * (bottom sheet vs default) must NOT be recomputed while the modal is open:
	 * if a mid-show re-render produces a different class string, Vue patches the
	 * class attribute wholesale and wipes Bootstrap's runtime `show` class —
	 * leaving an invisible display:block modal over a live backdrop. Refreshed
	 * on every show(), so the next open picks up the current viewport again.
	 */
	private activeMobileModeCss: string | null = null;
	private handleEscapeKeydown: ((e: KeyboardEvent) => void) | null = null;
	private handleModalShown: (() => void) | null = null;
	private handleModalHidden: (() => void) | null = null;

	mounted() {
		this.uuid = PortalUtils.randomString(6);
	}

	beforeUnmount() {
		this.detachEscapeListener();
		this.detachModalLifecycleListeners();
		ModalUtils.destroyModalInstance(this.$refs.modalRoot as Element);
	}

	private getFullId(): string {
		return `modal-` + `-${this.uuid}`;
	}

	private getLabelId(): string {
		return `${this.getFullId()}label`;
	}

	private getModalSizeCss(): string {
		if (this.size == ModalSize.Large) {
			return ' modal-lg';
		} else if (this.size == ModalSize.Small) {
			return ' modal-sm';
		} else if (this.size == ModalSize.ExtraLarge) {
			return ' modal-xl';
		} else if (this.size == ModalSize.FullWidth) {
			return ' modal-fw';
		} else if (this.size == ModalSize.NormalToLarge) {
			return ' modal-ntl';
		} else if (this.size == ModalSize.SmallToNormal) {
			return ' modal-stn';
		} else {
			return '';
		}
	}

	private getModalMobileModeCss(): string {
		if (this.treatMobileAsBottomSheet()) {
			return ' modal-bottom-sheet';
		} else if (this.treatMobileAsFullScreen()) {
			return ' modal-mobile-fullscreen';
		}

		return '';
	}

	private handleMobileModeDragBinding() {
		if (!this.treatMobileAsBottomSheet()) {
			return;
		}

		ModalUtils.bindModalBottomSheetHandle(() => this.$refs.modalRoot as HTMLElement, () => this.hide());
	}

	treatMobileAsBottomSheet(): boolean {
		return ModalConfig.useBottomSheet(this.mobileMode);
	}

	treatMobileAsFullScreen(): boolean {
		return (this.mobileMode == ModalMobileMode.FullScreen || (this.mobileMode == null && ModalConfig.defaultMobileMode == ModalMobileMode.FullScreen));
	}

	getModalInstance(): BootstrapModal {
		return BootstrapModal.getOrCreateInstance(this.$refs.modalRoot as Element);
	}

	public show(args?: ModalShowArgs) {
		args = args || {};
		this.modalShown = true;
		this.activeMobileModeCss = this.getModalMobileModeCss();

		// This modal instance is reused (modalShown is never reset to false), so a
		// previous interaction may have left an orphaned datatable dropdown overflow
		// clone behind — e.g. when the task table re-rendered under polling and broke
		// the clone's close-event link. Purge any such stale clone before showing so a
		// reopened modal never surfaces a dropdown that looks "still open".
		DropdownUtils.disposeAllClones();

		// Attach the ESC listener as early as possible — synchronously during show() — so that
		// even if the user presses ESC before Bootstrap's animation completes, our handler is
		// already registered. Re-attached in show.bs.modal as a backup; idempotent (handleEscapeKeydown
		// guard prevents double registration).
		this.attachEscapeListener();

		this.$nextTick(() => {
			if (!isNullOrEmpty(this.backdropCssClass)) {
				const instance: any = this.getModalInstance();
				if (instance._backdrop?._config) {
					instance._backdrop._config.className = `modal-backdrop ${this.backdropCssClass}`;
				}
			}

			this.attachModalLifecycleListeners();

			ModalUtils.showModal(this, {
				modal: this.getModalInstance(),
				onShown: args.onShown,
				onHidden: args.onHidden,
				onBeforeShown: args.onBeforeShown,
			});

			this.handleMobileModeDragBinding();
		});
	}

	public hide() {
		// Detach immediately to mirror show()'s eager attach. Also covered by hidden.bs.modal,
		// but defensive cleanup avoids a stale listener if hide() is called bypassing Bootstrap.
		this.detachEscapeListener();
		ModalUtils.hideModal({
			modal: this.getModalInstance(),
		});
	}

	private attachModalLifecycleListeners() {
		const modalEl = this.$refs.modalRoot as HTMLElement | null;
		if (!modalEl) {
			return;
		}

		// Detach any previous listeners before re-attaching (defensive — show() can be called multiple times).
		this.detachModalLifecycleListeners();

		const self = this;
		const nonStaticBackdrop = this.backdropStatic != true && this.dismissable != false;

		// Only register ESC handling for modals that allow keyboard dismiss (matches data-bs-keyboard semantics).
		if (!nonStaticBackdrop) {
			return;
		}

		this.handleModalShown = () => {
			self.attachEscapeListener.call(self);
		};

		this.handleModalHidden = () => {
			self.detachEscapeListener.call(self);
		};

		// `show.bs.modal` fires synchronously when modal.show() is called, before the fade-in
		// animation. Using this rather than `shown.bs.modal` avoids a race where the user presses
		// ESC during the 300ms fade-in and the listener isn't yet attached.
		modalEl.addEventListener('show.bs.modal', this.handleModalShown);
		modalEl.addEventListener('hidden.bs.modal', this.handleModalHidden);
	}

	private detachModalLifecycleListeners() {
		const modalEl = this.$refs.modalRoot as HTMLElement | null;

		if (modalEl != null && this.handleModalShown != null) {
			modalEl.removeEventListener('show.bs.modal', this.handleModalShown);
		}

		if (modalEl != null && this.handleModalHidden != null) {
			modalEl.removeEventListener('hidden.bs.modal', this.handleModalHidden);
		}

		this.handleModalShown = null;
		this.handleModalHidden = null;
	}

	private attachEscapeListener() {
		// Skip if keyboard dismiss is disabled (matches data-bs-keyboard="false" semantics).
		const nonStaticBackdrop = this.backdropStatic != true && this.dismissable != false;
		if (!nonStaticBackdrop) {
			return;
		}

		const modalId = this.getFullId();

		// Push this modal onto the stack — only the top of the stack handles ESC.
		if (!openModalsStack.includes(modalId)) {
			openModalsStack.push(modalId);
		}

		// Already registered.
		if (this.handleEscapeKeydown != null) {
			return;
		}

		const self = this;
		this.handleEscapeKeydown = (e: KeyboardEvent) => {
			if (e.key !== 'Escape') {
				return;
			}

			// Only the topmost open modal should respond to ESC, so stacked modals don't double-close.
			if (openModalsStack[openModalsStack.length - 1] !== modalId) {
				return;
			}

			// If any portal-rendered widget (Select2, smart-dropdown, Bootstrap dropdown,
			// autocomplete, datepicker) is open, let it handle ESC first. Closing the modal at
			// the same time would surprise the user.
			//
			// We check this in the capture phase (see addEventListener call below) so the
			// widget's bubble-phase ESC handler hasn't yet run — meaning open-state classes
			// are still in the DOM and detectable here.
			if (self.isPortalWidgetOpen.call(self)) {
				return;
			}

			self.hide.call(self);
		};

		// `capture: true` so this fires BEFORE widget-level bubble listeners (Select2 closes its
		// dropdown on bubble-phase ESC; if we listened in bubble phase, we'd race against it and
		// see `.select2-container--open` already removed). Capture lets us inspect DOM state
		// before any inner widget has had a chance to react to this same keydown.
		document.addEventListener(
			'keydown',
			this.handleEscapeKeydown,
			true,
		);
	}

	private isPortalWidgetOpen(): boolean {
		// New DropdownList panel: a `.pd-dd-panel` is in the DOM only while open.
		if (document.querySelector('.pd-dd-panel') != null) {
			return true;
		}

		// Smart-dropdown (powerduck) renders `.filter-dropdown` only when open.
		if (document.querySelector('.filter-dropdown') != null) {
			return true;
		}

		// Bootstrap-native dropdown.
		if (document.querySelector('.dropdown-menu.show') != null) {
			return true;
		}

		return false;
	}

	private detachEscapeListener() {
		if (this.handleEscapeKeydown != null) {
			// Must match the `capture: true` used at addEventListener time.
			document.removeEventListener(
				'keydown',
				this.handleEscapeKeydown,
				true,
			);
			this.handleEscapeKeydown = null;
		}

		const modalId = this.getFullId();
		const idx = openModalsStack.indexOf(modalId);
		if (idx !== -1) {
			openModalsStack.splice(idx, 1);
		}
	}

	render() {
		const nonStaticBackdrop = this.backdropStatic != true && this.dismissable != false;
		if (!this.modalShown && this.modalLazyMode != false) {
			return null;
		}

		// QA__G-159: Always set data-bs-keyboard="false" so Bootstrap doesn't register its
		// own ESC handler on the modal element. We handle ESC ourselves via a document-level
		// keydown listener (see attachEscapeListener), which gives us reliable behavior:
		// - ESC works even when focus is on <body> (default state right after modal opens).
		// - Portal-rendered widgets inside the modal (Select2, smart-dropdown, etc.) get to
		//   handle ESC first; we only close the modal when no such widget is open.
		// - Doesn't trigger Bootstrap's _enforceFocus() which would break Select2's search input.
		return (
			<Teleport to={ModalConfig.defaultPortalTarget}>
				<div
					id={this.getFullId()}
					key={this.getFullId()}
					ref="modalRoot"
					class={`modal fade${this.activeMobileModeCss ?? this.getModalMobileModeCss()} ${this.cssClass || ''}`}
					role={this.ariaRole || 'dialog'}
					tabindex={-1} // Standard Bootstrap markup: makes the dialog programmatically
					// focusable so Bootstrap moves focus into it on open (WCAG 2.4.3). Was missing,
					// leaving focus behind the backdrop on the trigger. -1 keeps it out of Tab order.
					aria-modal="true"
					aria-labelledby={this.getLabelId()}
					aria-hidden="true"
					data-bs-focus={PortalUtils.treatAsMobileDevice() ? 'false' : undefined} // Causes issue when FDD is shown
					data-bs-backdrop={nonStaticBackdrop ? 'true' : 'static'}
					data-bs-keyboard="false"
					data-preventhistory={this.preventHistoryEntry == true ? 'true' : null}
				>
					<div class={`modal-dialog${this.getModalSizeCss()}`} role="document">
						<div class="modal-content">
							<div class="drag-handle text-center">
								<div class="handle-bar"></div>
							</div>

							<LoadingIndicator visible={this.blocked} />

							<div class={`modal-header${this.icon == null ? '' : ' modal-has-headericon'}`}>
								{this.icon != null && <div class="modal-header-icon">{this.renderModalHeaderIcon()}</div>}

								<h5 class="modal-title" id={this.getLabelId()}>
									{this.title}
								</h5>

								{this.dismissable != false && <button type="button" class="btn-close close" data-bs-dismiss="modal" aria-label={PowerduckState.getResourceValue('close')}></button>}
							</div>

							{this.$slots.default?.()}
						</div>
					</div>
				</div>
			</Teleport>
		);
	}

	renderModalHeaderIcon() {
		if (ModalConfig.renderModalHeaderIcon != null) {
			return ModalConfig.renderModalHeaderIcon(this.icon);
		}

		if (this.icon == ModalHeaderIcon.Warning) {
			return <ModalIconWarning visible={true} />;
		} else if (this.icon == ModalHeaderIcon.Success) {
			return <ModalAnimationSuccess visible={true} />;
		} else if (this.icon == ModalHeaderIcon.Error) {
			return <ModalAnimationError visible={true} />;
		} else if (this.icon == ModalHeaderIcon.Question) {
			return <ModalIconWarning visible={true} />;
		}

		return null;
	}
}

const Modal = toNative(ModalComponent);
export type ModalType = typeof Modal.prototype;
export default Modal;
