import type { VNode } from 'vue';
import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item';
import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper';
import { Prop, toNative } from 'vue-facing-decorator';
import PowerduckState from '../../app/powerduck-state';
import TsxComponent, { Component } from '../../app/vuetsx';
import FormItemWrapper, { HintType } from '../form/form-item-wrapper';
import { InputSpinner } from './ts/bootstrapInputSpinner';
import './css/input-spinner.css';

export enum NumericInputMode {
	Clasic = 'clasic',
	Spinner = 'spinner',
}

// TODO: Turn into approperiate types
interface NumericInputArgs extends Omit<FormItemWrapperArgs, 'errorId'> {
	value: number;
	maxValue?: number;
	minValue?: number;
	fullWidth?: boolean;
	step?: number;
	updateMode?: 'input' | 'change';
	placeholder?: string;
	decimalsAlwaysVisible?: boolean;
	/**
	 * QA_AT-24: when set (Classic mode only), the input clamps the visible
	 * value to at most this many decimal places AS THE USER TYPES — not only
	 * on blur / save. Excess decimal characters are stripped via string
	 * truncation (NOT numeric rounding) so digits the operator typed are
	 * never silently rewritten; the DOM `input.value` is rewritten in place.
	 * Intermediate states like `12.` are preserved so the operator can
	 * continue typing.
	 *
	 * The `changed` callback respects `updateMode`: under the default
	 * (change-on-blur) it is NOT fired per-keystroke even when truncation
	 * happens — the trailing native `change` on blur reads the truncated DOM
	 * value and propagates it. Callers that want per-keystroke updates must
	 * opt in with `updateMode='input'`.
	 *
	 * Undefined = legacy unbounded-precision behaviour preserved for all
	 * existing callers. Spinner mode ignores this prop (Spinner editor
	 * already enforces decimals via Intl.NumberFormat on blur).
	 */
	maxDecimals?: number;
	changed: (newValue: number) => void;
	mode?: NumericInputMode;
	disabled?: boolean;
	/** Accessible name when there is no visible `label` (WCAG 4.1.2 / 3.3.2). */
	ariaLabel?: string;
}

@Component
class NumericInputComponent extends TsxComponent<NumericInputArgs> implements NumericInputArgs {
	@Prop() label!: string | VNode;
	@Prop() labelButtons!: DropdownButtonItemArgs[];
	@Prop() subtitle!: string;
	@Prop() value!: number;
	@Prop() mandatory!: boolean;
	@Prop() placeholder!: string;
	@Prop() changed!: (newValue: number) => void;
	@Prop() maxValue!: number;
	@Prop() minValue!: number;
	@Prop() fullWidth!: boolean;
	@Prop() wrap!: boolean;
	@Prop() step!: number;
	@Prop() hint: string;
	@Prop() hintType: HintType;
	@Prop() marginType?: MarginType;
	@Prop() maxWidth?: number;
	@Prop() appendIcon: string;
	@Prop() prependIcon: string;
	@Prop() showClearValueButton!: boolean;
	@Prop() appendClicked: () => void;
	@Prop() prependClicked: () => void;
	@Prop() decimalsAlwaysVisible!: boolean;
	@Prop() maxDecimals?: number;
	@Prop() mode: NumericInputMode;
	@Prop() updateMode?: 'input' | 'change';
	@Prop() disabled?: boolean;
	@Prop() cssClass?: string;
	/**
	 * Accessible name for the numeric field. Use when there is no visible
	 * `label` (e.g. a quantity stepper next to a product name) so the control
	 * is not announced as an unnamed edit field — WCAG 4.1.2 / 3.3.2. In Spinner
	 * mode this is mirrored onto the visible spinner input by InputSpinner.
	 */
	@Prop() ariaLabel?: string;

	private get errorId(): string {
		return `numeric-error-${this.$.uid}`;
	}

	raiseChangeEvent(e) {
		this.populateValidationDeclaration();

		if (this.changed != null) {
			let newValue = e.target.value;
			if (newValue == null || newValue === '') {
				newValue = null;
			} else {
				try {
					newValue = Number(newValue);
					if (isNaN(newValue)) {
						newValue = null;
					}
				} catch (e) {
					newValue = null;
				}
			}

			this.changed(newValue);
		}
	}

	handleClassicInput(e) {
		if (this.decimalsAlwaysVisible == true) {
			const value = parseFloat(e.value);
			const decimals = this.getDecimals();

			if (decimals > 0 && !isNaN(value)) {
				(this.$el as HTMLElement).querySelector('input').value = value.toFixed(decimals);
			}
		}
	}

	/**
	 * QA_AT-24: when `maxDecimals` is set and the typed value carries more
	 * decimal places than allowed, rewrite the DOM `input.value` to the
	 * truncated representation. Returns the truncated numeric value, or
	 * `null` when no truncation happened or the input is not yet a complete
	 * number — e.g. the intermediate state `12.` keeps its trailing dot so
	 * the operator can continue typing.
	 *
	 * Non-destructive for intermediate states: typing `12.` leaves `12.` in
	 * the DOM; only EXCESS decimal characters past `maxDecimals` are
	 * stripped. Uses string truncation (NOT numeric rounding) so digits the
	 * operator already typed are never silently rewritten — the save-side
	 * `parseFloat(e.toFixed(6))` in GpsInput / GpsBoundingBoxInput remains as
	 * a defence-in-depth rounding net for non-input paths.
	 */
	private clampToMaxDecimals(inputEl: HTMLInputElement): number | null {
		if (this.maxDecimals == null || this.maxDecimals < 0) {
			return null;
		}

		const raw = inputEl.value;
		if (raw == null || raw === '') {
			return null;
		}

		// Preserve trailing "." and partial inputs like "12." — they have a
		// length-0 decimal substring which is <= cap.
		const dotIndex = raw.indexOf('.');
		if (dotIndex < 0) {
			return null;
		}

		const decimalPart = raw.slice(dotIndex + 1);
		if (decimalPart.length <= this.maxDecimals) {
			return null;
		}

		const truncatedString = raw.slice(0, dotIndex + 1 + this.maxDecimals);
		inputEl.value = truncatedString;
		const parsed = parseFloat(truncatedString);

		return isNaN(parsed) ? null : parsed;
	}

	getMode(): NumericInputMode {
		if (this.mode != null) {
			return this.mode;
		} else {
			return NumericInputMode.Spinner;
		}
	}

	getValue(): number | null {
		return this.value ?? null;
	}

	getDecimals(): number {
		if (this.step != null) {
			const step: string = String(this.step);
			const dec = step.split('.', 2)[1]?.length;
			return dec || 0;
		} else {
			return 0;
		}
	}

	/**
	 * QA_AT-11: clamp a committed numeric model value to at most `maxDecimals`
	 * decimal places for DISPLAY — applied in `render()` (initial paint) and
	 * `updated()` (re-render) so an UNFOCUSED input never shows more decimals
	 * than allowed, regardless of how the value reached the model (map
	 * right-click, programmatic prefill, paste, legacy data). Without this the
	 * QA bounce reproduced: a raw 15-decimal map-click longitude rendered
	 * verbatim because both rounding layers (typing-time onInput clamp,
	 * blur-time `changed` round) are bypassed by a programmatic model write.
	 *
	 * Uses STRING TRUNCATION (NOT numeric rounding) to stay consistent with the
	 * QA_AT-24 typing-time `clampToMaxDecimals` contract (`49.0631912345` →
	 * `49.063191`, never `49.063192`). Opt-in: returns the value untouched when
	 * `maxDecimals` is unset, so non-GPS NumericInputs are unaffected. The
	 * in-progress typed value is never stomped — both callers only apply this to
	 * UNFOCUSED inputs.
	 */
	private formatDisplayValue(value: number | null): string {
		if (value == null) {
			return '';
		}

		const raw = String(value);
		if (this.maxDecimals == null || this.maxDecimals < 0) {
			return raw;
		}

		const dotIndex = raw.indexOf('.');
		if (dotIndex < 0) {
			return raw;
		}

		const decimalPart = raw.slice(dotIndex + 1);
		if (decimalPart.length <= this.maxDecimals) {
			return raw;
		}

		return raw.slice(0, dotIndex + 1 + this.maxDecimals);
	}

	render(h) {
		const mode = this.getMode();
		if (mode == NumericInputMode.Clasic) {
			let inputValue = this.getValue();
			if (this.decimalsAlwaysVisible == true && inputValue != null) {
				inputValue = inputValue.toFixed(this.getDecimals()) as any;
			} else if (this.maxDecimals != null && inputValue != null) {
				// QA_AT-11: clamp the initial displayed value to maxDecimals so a
				// raw model value (e.g. map-click longitude) never paints >6
				// decimals. String truncation, opt-in — see formatDisplayValue.
				inputValue = this.formatDisplayValue(inputValue) as any;
			}

			return (
				<FormItemWrapper
					label={this.label}
					mandatory={this.mandatory}
					wrap={this.wrap}
					appendIcon={this.appendIcon}
					prependIcon={this.prependIcon}
					hint={this.hint}
					hintType={this.hintType}
					appendClicked={this.appendClicked}
					prependClicked={this.prependClicked}
					marginType={this.marginType}
					maxWidth={this.maxWidth}
					validationState={this.validationState}
					labelButtons={this.labelButtons}
					subtitle={this.subtitle}
					cssClass={this.cssClass}
					showClearValueButton={this.showClearValueButton}
					errorId={this.errorId}
				>
					<input
						type="number"
						max={this.maxValue}
						min={this.minValue}
						step={this.step}
						value={inputValue}
						onChange={e => this.raiseChangeEvent(e)}
						onInput={(e) => {
							// QA_AT-24: input-time decimal clamp. Always trims the DOM
							// `input.value` when `maxDecimals` is exceeded so the operator
							// never sees more digits than allowed; the `changed` callback
							// however respects `updateMode` — under the default
							// change-on-blur contract the trailing native `change` reads
							// the truncated DOM value and propagates it, so we don't fire
							// per-keystroke (which previously caused mid-typing reactive
							// storms on `gpsModel.latitude` → map re-centering →
							// validation re-runs).
							if (this.maxDecimals != null && this.maxDecimals >= 0) {
								const truncated = this.clampToMaxDecimals(e.target as HTMLInputElement);
								if (truncated != null && this.changed != null && this.updateMode == 'input') {
									this.changed(truncated);
								}
							}

							if (this.updateMode == 'input') {
								this.handleClassicInput(e);
							}
						}}
						class={PowerduckState.getFormControlCssClass()}
						placeholder={this.placeholder}
						disabled={this.disabled as any != true ? null : 'disabled'}
						aria-label={this.ariaLabel || undefined}
						aria-invalid={this.hasValidationError ? 'true' : undefined}
						aria-describedby={this.hasValidationError ? this.errorId : undefined}
						aria-required={this.mandatory ? 'true' : undefined}
					/>
				</FormItemWrapper>
			);
		} else {
			const decimals = this.getDecimals();
			return (
				<FormItemWrapper
					label={this.label}
					mandatory={this.mandatory}
					wrap={this.wrap}
					appendIcon={this.appendIcon}
					prependIcon={this.prependIcon}
					hint={this.hint}
					hintType={HintType.Label}
					appendClicked={this.appendClicked}
					prependClicked={this.prependClicked}
					marginType={this.marginType}
					maxWidth={this.maxWidth}
					validationState={this.validationState}
					labelButtons={this.labelButtons}
					subtitle={this.subtitle}
					cssClass={this.cssClass}
					showClearValueButton={this.showClearValueButton}
					errorId={this.errorId}
				>
					<div class={`input-spinner-wrap${this.fullWidth == true ? ' input-spinner-fullwidth' : ''}`}>
						<input
							type="number"
							min={this.minValue}
							max={this.maxValue}
							step={this.step}
							value={this.getValue()}
							placeholder={this.placeholder}
							onChange={e => this.raiseChangeEvent(e)}
							onInput={(e) => {
								if (this.updateMode == 'input') {
									this.raiseChangeEvent(e);
								}
							}}
							class={`${PowerduckState.getFormControlCssClass()} input-spinner`}
							data-decimals={decimals}
							disabled={this.disabled as any != true ? null : 'disabled'}
							aria-label={this.ariaLabel || undefined}
							aria-invalid={this.hasValidationError ? 'true' : undefined}
							aria-describedby={this.hasValidationError ? this.errorId : undefined}
							aria-required={this.mandatory ? 'true' : undefined}
						/>
					</div>
				</FormItemWrapper>
			);
		}
	}

	mounted() {
		const mode = this.getMode();
		if (mode == NumericInputMode.Spinner) {
			// eslint-disable-next-line no-new
			new InputSpinner(this.$el.querySelector('input'));
		}
	}

	updated() {
		const newValue = this.getValue();
		const isClassic = this.getMode() == NumericInputMode.Clasic;
		const applyDecimals = isClassic && this.decimalsAlwaysVisible == true && newValue != null;
		const decimals = applyDecimals ? this.getDecimals() : 0;
		// QA_AT-11: when maxDecimals is set (Classic GPS / bbox inputs) and
		// decimalsAlwaysVisible is not, truncate the re-rendered unfocused value
		// so a raw model value never persists >maxDecimals decimals in the DOM.
		// String truncation, opt-in — see formatDisplayValue. The focus guard
		// below still protects the in-progress typed value.
		const applyMaxDecimals = isClassic && !applyDecimals && this.maxDecimals != null && newValue != null;
		let renderedValue: string | number;
		if (applyDecimals) {
			renderedValue = newValue.toFixed(decimals);
		} else if (applyMaxDecimals) {
			renderedValue = this.formatDisplayValue(newValue);
		} else {
			renderedValue = newValue ?? '';
		}

		(this.$el as HTMLElement)?.querySelectorAll('input').forEach((input) => {
			// Don't stomp the in-progress value while the user is typing. The Spinner-mode
			// value interceptor + InputSpinner editor would otherwise re-format `input.value`
			// to `Intl.NumberFormat(...)` then a follow-up re-render snaps it back to the
			// stale prop — QA_GO-429 Bug B.
			const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLElement | null;
			const isThisInputFocused = active === input
				|| (active != null && active.closest('.input-spinner-wrap')?.contains(input) === true);

			if (isThisInputFocused) {
				return;
			}

			input.value = String(renderedValue);
		});
	}
}

const NumericInput = toNative(NumericInputComponent);
export default NumericInput;
