import jspreadsheet from 'jspreadsheet-ce';
import { Prop, toNative } from 'vue-facing-decorator';
import TsxComponent, { Component } from '../../app/vuetsx';
import { isNullOrEmpty } from '../../common/utils/is-null-or-empty';
import { getSpreadsheetRowsRuntimeData, spreadsheetCellValuesEqual } from './spreadsheet-data';
import { createVirtualizationPlugin, type VirtualizationOptions } from './virtualization-plugin';
import 'jspreadsheet-ce/dist/jspreadsheet.css';
import 'jsuites/dist/jsuites.css';
import './css/spreadsheet.css';

export interface SpreadsheetArgs {
	rows: any[][];
	autoTrackData?: boolean;
	allowToolbar?: boolean;
	columns: SpreadsheetColumn[];
	rootCssClass?: string;
	columnResize?: boolean;
	rowResize?: boolean;
	rowDrag?: boolean;
	columnsDrag?: boolean;
	nestedHeaders?: NestedHeaderCell[][];
	contextMenu?: (el, x, y, e) => SpreadsheetContextMenuItem[];
	mergeCells?: { [index: string]: number[] };
	beforeChange?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void;
	changed?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void;
	afterChanges?: () => void;
	updateTable?: (instance?: any, cell?: any, col?: number, row?: number, val?: any, id?: any) => void;
	/**
	 * Maps to jspreadsheet-ce v5's `ondeleterow`, fired AFTER one or more rows are
	 * removed. `removedRows` holds the pre-delete row indexes of the removed rows.
	 */
	deleteRow?: (instance?: any, removedRows?: number[]) => void;
	rowHeight?: number;
	/**
	 * Toggle row virtualization. When true, only rows close to the viewport are kept
	 * visible; rows outside the window are display-hidden. Off by default — opt in for
	 * very large sheets where jspreadsheet's own `lazyLoading` either does not engage
	 * or is insufficient.
	 */
	virtualize?: boolean;
	/**
	 * Total number of rows kept visible when `virtualize` is true. The window is
	 * centered roughly on the scroll position. Default 300.
	 */
	virtualWindowSize?: number;
	/** Activate the table lazyloading. */
	lazyLoading?: boolean;
	/** Number of columns frozen at the top of the spreadsheet. */
	freezeColumns?: number;
	/**
	 * Enable column filters.
	 * @default false
	 */
	filters?: boolean;
	/** Number of rows per page. */
	pagination?: number;
	/**
	 * Values available in the dropdown for choosing the number of rows per page.
	 *
	 * This dropdown is only visible when the `search` option is true and the
	 * `pagination` option is greater than 0.
	 */
	paginationOptions?: number[];
	/**
	 * Allow table overflow.
	 * @default false
	 */
	tableOverflow?: boolean;
}

export interface SpreadsheetContextMenuItem {
	title: string;
	onclick: () => void;
}

/** Mirrors jspreadsheet-ce's `NestedHeaderCell` (v5). */
export interface NestedHeaderCell {
	id?: string;
	colspan?: number;
	title?: string;
	align?: string;
}

/**
 * @deprecated Use `NestedHeaderCell`. Kept as an alias so existing consumers
 * that import `SpreadsheetNestedHeader` keep compiling.
 */
export type SpreadsheetNestedHeader = NestedHeaderCell;

/**
 * Column types supported by the powerduck `Spreadsheet`.
 *
 * Empty cells in a `numeric` column are preserved as `''` (and therefore `null`
 * after the consumer's `toNumber` parse) rather than coerced to the literal
 * `0` — see `mapColumnsForJspreadsheet` below.
 *
 * `nullableNumeric` is kept as a back-compat alias and behaves identically to
 * `numeric`. New code should use `numeric`.
 */
export interface SpreadsheetColumn {
	type: 'text' | 'dropdown' | 'calendar' | 'image' | 'checkbox' | 'numeric' | 'nullableNumeric' | 'color' | 'hidden';
	title: string;
	width: string;
	source?: string[] | { id: number | string; name: string }[];
	mask?: string;
	decimal?: string;
	readOnly?: boolean;
	multiple?: boolean;
}

/**
 * jspreadsheet-ce (v5) coerces a cleared `numeric` cell to the literal `0`
 * unless `allowEmpty: true` is passed (see the closeEditor branch in
 * `dist/index.js`: `r = column.allowEmpty ? '' : 0`). That coercion is
 * indistinguishable from a deliberately-priced zero, so cleared price cells
 * silently become "FREE" rows in admin grids. `allowEmpty: true` is therefore
 * applied to every numeric column by default; the `nullableNumeric` legacy
 * alias maps to the same shape.
 */
const mapColumnsForJspreadsheet = (columns: SpreadsheetColumn[]): any[] => {
	if (!columns) {
		return columns as any;
	}

	return columns.map((c) => {
		if (c.type === 'numeric' || c.type === 'nullableNumeric') {
			return {
				...c,
				type: 'numeric',
				allowEmpty: true,
			};
		}

		return c;
	});
};

@Component
class SpreadsheetComponent extends TsxComponent<SpreadsheetArgs> implements SpreadsheetArgs {
	@Prop() rows: any[][];
	@Prop() autoTrackData?: boolean;
	@Prop() allowToolbar?: boolean;
	@Prop() columns: SpreadsheetColumn[];
	@Prop() columnResize?: boolean;
	@Prop() rowResize?: boolean;
	@Prop() rowDrag?: boolean;
	@Prop() columnsDrag?: boolean;
	@Prop() rootCssClass?: string;
	@Prop() nestedHeaders?: NestedHeaderCell[][];
	@Prop() beforeChange?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void;
	@Prop() changed?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void;
	@Prop() afterChanges?: () => void;
	@Prop() contextMenu?: (el, x, y, e) => SpreadsheetContextMenuItem[];
	@Prop() mergeCells?: { [index: string]: number[] };
	@Prop() updateTable?: (instance?: any, cell?: any, col?: number, row?: number, val?: any, id?: any) => void;
	@Prop() deleteRow?: (instance?: any, removedRows?: number[]) => void;
	@Prop() rowHeight?: number;
	@Prop({ default: false }) virtualize?: boolean;
	@Prop({ default: 300 }) virtualWindowSize?: number;
	@Prop() lazyLoading?: boolean;
	@Prop() freezeColumns?: number;
	@Prop() filters?: boolean;
	@Prop() pagination?: number;
	@Prop() paginationOptions?: number[];
	@Prop() tableOverflow?: boolean;
	private _instances: any[] = [];
	// True while a cell editor is open (between jspreadsheet's oneditionstart /
	// oneditionend). `updated()` reads it to skip the model→grid sync mid-edit.
	private _editing: boolean = false;

	private getRuntimeColumnCount(): number {
		return Math.max(this.columns?.length || 0, 1);
	}

	private getRuntimeRows(): any[][] {
		const rows = getSpreadsheetRowsRuntimeData(this.rows);
		if (rows.length > 0) {
			return rows;
		}

		return [Array.from({ length: this.getRuntimeColumnCount() }, () => '')];
	}

	mounted() {
		// `nullableNumeric` columns are mapped to a jspreadsheet `numeric` column
		// with `allowEmpty`, so clearing a cell commits an empty string. jspreadsheet
		// skips the write whenever that empty string loose-equals the cell's previous
		// value — which happens only when the previous value is 0 (`0 == ''` in JS).
		// The cell is then left showing 0 and no `onchange` fires, so a 0-valued cell
		// could never be cleared. On edition end, detect that stuck case and force the
		// empty value through as `null` (`0 != null`), which dispatches `onchange`.
		// Track active cell editing so `updated()` can skip the model→grid sync while an
		// editor is open — otherwise setData() closes the editor and discards in-progress
		// input (the consumer often re-renders mid-edit while flushing into its reactive
		// model). oneditionend fires for both save and cancel, so `_editing` always clears.
		const onEditionStart = (): void => {
			this._editing = true;
		};

		const onEditionEnd = (
			instance: any,
			_td: any,
			x: number,
			y: number,
			editorValue: any,
			wasSaved: boolean,
		): void => {
			this._editing = false;

			const column = this.columns?.[x];
			if (column == null || column.type !== 'nullableNumeric') {
				return;
			}

			if (!wasSaved || !isNullOrEmpty(editorValue)) {
				return;
			}

			const current = instance?.getValueFromCoords?.(x, y);
			const currentText = current == null ? '' : String(current).trim();
			if (currentText.length > 0 && Number(currentText.replace(',', '.')) === 0) {
				instance.setValueFromCoords(
					x,
					y,
					null,
					true,
				);
			}
		};

		const worksheetOptions: any = {
			data: this.getRuntimeRows(),
			columns: mapColumnsForJspreadsheet(this.columns),
			minDimensions: [
				this.getRuntimeColumnCount(),
				1,
			],
			mergeCells: this.mergeCells || {},
			columnResize: this.columnResize != false,
			rowResize: this.rowResize != false,
			rowDrag: this.rowDrag != false,
			columnsDrag: this.columnsDrag != false,
			nestedHeaders: this.nestedHeaders,
			contextMenu: this.contextMenu,
			onbeforechange: this.beforeChange,
			onchange: this.changed,
			onafterchanges: this.afterChanges,
			ondeleterow: this.deleteRow,
			oneditionstart: onEditionStart,
			oneditionend: onEditionEnd,
		};

		// Forward optional jspreadsheet-native worksheet options only when the consumer
		// passed them — leaving them undefined preserves jspreadsheet's own defaults.
		if (this.lazyLoading != null) {
			worksheetOptions.lazyLoading = this.lazyLoading;
		}

		if (this.freezeColumns != null) {
			worksheetOptions.freezeColumns = this.freezeColumns;
		}

		if (this.filters != null) {
			worksheetOptions.filters = this.filters;
		}

		if (this.pagination != null) {
			worksheetOptions.pagination = this.pagination;
		}

		if (this.paginationOptions != null) {
			worksheetOptions.paginationOptions = this.paginationOptions;
		}

		if (this.tableOverflow != null) {
			worksheetOptions.tableOverflow = this.tableOverflow;
		}

		if (this.updateTable) {
			worksheetOptions.updateTable = this.updateTable;
		}

		// Register the virtualization plugin only when opted in. The factory captures the
		// requested window size so multiple sheets on a page can each have their own.
		const plugins: Record<string, () => any> = {};
		if (this.virtualize === true) {
			const opts: VirtualizationOptions = { windowSize: this.virtualWindowSize ?? 300 };
			plugins.virtualization = () => createVirtualizationPlugin(opts);
		}

		// jspreadsheet-ce 5.x dispatches lifecycle events (`onchange`,
		// `onbeforechange`, `onafterchanges`, …) via the TOP-LEVEL spreadsheet
		// config rather than the worksheet config — see `dispatch.A`:
		//   `r = t.parent ? t.parent : t`
		//   `if (typeof r.config[e] === 'function') r.config[e].apply(...)`.
		// Mirroring the handlers at both levels keeps the worksheet config self-
		// describing while ensuring the dispatcher actually fires the callbacks
		// the consumer wired in. Without the top-level mirror the worksheet
		// handlers are silently ignored.
		const spreadsheetOptions: any = {
			worksheets: [worksheetOptions],
			toolbar: this.allowToolbar,
			onbeforechange: this.beforeChange,
			onchange: this.changed,
			onafterchanges: this.afterChanges,
			ondeleterow: this.deleteRow,
			oneditionstart: onEditionStart,
			oneditionend: onEditionEnd,
		};

		if (this.updateTable) {
			spreadsheetOptions.updateTable = this.updateTable;
		}

		if (Object.keys(plugins).length > 0) {
			spreadsheetOptions.plugins = plugins;
		}

		this._instances = jspreadsheet(this.$el as HTMLDivElement, spreadsheetOptions);

		if (this.rowHeight) {
			this.setRowHeight();
		}
	}

	private getMainInstance(): any {
		return this._instances?.[0];
	}

	setRowHeight() {
		const main = this.getMainInstance();
		if (main == null) {
			return;
		}

		const rowCount = this.rows.length;
		for (let i = 0; i < rowCount; i++) {
			main.setHeight(i, this.rowHeight);
		}
	}

	insertRow(
		rowNumber: number,
		values: string[],
		height?: number,
	) {
		const main = this.getMainInstance();
		if (main == null) {
			return;
		}

		main.insertRow(
			values,
			rowNumber,
			true,
		);
		main.setHeight(rowNumber, height || this.rowHeight);
	}

	getData(): any[][] {
		return this.getMainInstance()?.getData() || [];
	}

	dataHaveChanged(): boolean {
		const main = this.getMainInstance();
		if (main == null) {
			return false;
		}

		const currentData = main.getData();
		const newData = this.getRuntimeRows();

		if (currentData?.length != newData?.length) {
			return true;
		}

		for (let i = 0, len = newData.length; i < len; i++) {
			const oldRow = currentData[i];
			const newRow = newData[i];

			if (oldRow?.length != newRow?.length) {
				return true;
			}

			for (let j = 0, lenJ = oldRow.length; j < lenJ; j++) {
				if (!spreadsheetCellValuesEqual(oldRow[j], newRow[j])) {
					return true;
				}
			}
		}

		return false;
	}

	updated() {
		// Don't sync the model into the grid while the user is editing a cell — setData()
		// would close the open editor and discard in-progress input. The re-render that
		// fires right after edition ends (when the consumer flushes the committed value)
		// performs the sync instead.
		if (this._editing) {
			return;
		}

		if (this.autoTrackData != false && this.dataHaveChanged()) {
			this.getMainInstance()?.setData(this.getRuntimeRows());
		}
	}

	render(h) {
		return <div data-rowcount={this.rows?.length || 0} class={`spreadsheet-root${!isNullOrEmpty(this.rootCssClass) ? ` ${this.rootCssClass}` : ''}`} />;
	}
}

const Spreadsheet = toNative(SpreadsheetComponent);
export default Spreadsheet;
