/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 */

import { instanceSymbol } from "../../constants.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import { CustomElement, getSlottedElements } from "../../dom/customelement.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { findTargetElementFromEvent } from "../../dom/events.mjs";
import { BoardStyleSheet } from "./stylesheet/board.mjs";
import { Observer } from "../../types/observer.mjs";

export { Board };

/**
 * @private
 * @type {symbol}
 */
export const boardElementSymbol = Symbol("boardElement");

/**
 * @private
 * @type {symbol}
 */
export const gridElementSymbol = Symbol("gridElement");

/**
 * @private
 * @type {symbol}
 */
export const parkingElementSymbol = Symbol("parkingElement");

/**
 * A Board
 *
 * @fragments /fragments/components/layout/board/
 *
 * @example /examples/components/layout/board-simple
 * @example /examples/components/layout/board-kanban Kanban Board
 *
 * @since 3.116.0
 * @copyright Volker Schukai
 * @summary A board layout with drag-and-drop support for dashboards, kanban views and modular content grids.
 */
class Board extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @returns {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/layout/board@@instance");
	}

	/**
	 *
	 * @return {Components.Layout.Board
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initControlReferences.call(this);
		initEventHandler.call(this);
		assignDraggableToAllSlottedElements.call(this);
		return this;
	}

	/**
	 * To set the options via the HTML Tag, the attribute `data-monster-options` must be used.
	 * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
	 *
	 * The individual configuration values can be found in the table.
	 *
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @property {Object} dimensions Dimensions of the board
	 * @property {number} dimensions.0.rows Number of rows for the first breakpoint
	 * @property {number} dimensions.0.columns Number of columns for the first breakpoint
	 * @property {number} dimensions.600.rows Number of rows for the second breakpoint
	 * @property {number} dimensions.600.columns Number of columns for the second breakpoint
	 * @property {number} dimensions.1200.rows Number of rows for the third breakpoint
	 * @property {number} dimensions.1200.columns Number of columns for the third breakpoint
	 * @property {number} dimensions.1800.rows Number of rows for the fourth breakpoint
	 * @property {number} dimensions.1800.columns Number of columns for the fourth breakpoint
	 * @property {string} fillMode Fill mode for the board ("top", "bottom", "left", "right", "none")
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},

			dimensions: {
				0: {
					rows: 8,
					columns: 1,
				},
				600: {
					rows: 4,
					columns: 2,
				},
				1200: {
					rows: 4,
					columns: 3,
				},
				1800: {
					rows: 8,
					columns: 1,
				},
			},

			fillMode: "none", // "top", "bottom", "left", "right", "none"
		});
	}

	/**
	 * @return {string}
	 */
	static getTag() {
		return "monster-board";
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [BoardStyleSheet];
	}
}

function assignDraggableToAllSlottedElements() {
	const elements = getSlottedElements.call(this, "");
	for (const element of elements) {
		if (element instanceof HTMLElement) {
			element.setAttribute("draggable", "true");
		}
	}
}

/**
 * @private
 * @return {initEventHandler}
 */
function initEventHandler() {
	const self = this;
	const element = this[gridElementSymbol];

	var dragInfo = {
		element: undefined,
	};

	this.attachObserver(
		new Observer(() => {
			initGrid.call(self);
		}),
	);

	setTimeout(() => {
		initGrid.call(self);
	});

	element.addEventListener("drop", function (event) {
		event.preventDefault();

		const gridInfo = getGridInfo(element);
		const dropCell = getDropTargetCell(element, event, gridInfo);
		const mode = self.getOption("fillMode") || "none";

		const occupant = getElementAtCell.call(self, element, dropCell);
		if (occupant && occupant !== dragInfo.element) {
			shiftElement.call(self, occupant, element);
		}

		const targetCell = findEmptyCellInMode.call(
			self,
			element,
			dropCell,
			gridInfo,
			dragInfo.element,
			mode,
		);
		moveElementToCell(dragInfo.element, targetCell);

		if (dragInfo.originalCell) {
			if (
				(mode === "top" || mode === "bottom") &&
				dragInfo.originalCell.col !== targetCell.col
			) {
				// Ursprüngliche Spalte neu ordnen
				rebalanceColumn.call(
					self,
					element,
					dragInfo.originalCell.col,
					gridInfo,
					mode,
				);
			} else if (
				(mode === "left" || mode === "right") &&
				dragInfo.originalCell.row !== targetCell.row
			) {
				// Ursprüngliche Zeile neu ordnen
				rebalanceRow.call(
					self,
					element,
					dragInfo.originalCell.row,
					gridInfo,
					mode,
				);
			}
		}

		// Aufräumen: Markierung entfernen usw.
		dragInfo.element.classList.remove("dragging");
		dragInfo.element.style.opacity = "1";
		dragInfo.element = undefined;
		dragInfo.originalCell = undefined;
	});

	let clickedHandle = null;
	const markElementHandle = (event) => {
		clickedHandle = findTargetElementFromEvent(
			event,
			"data-monster-role",
			"handle",
		);
		if (!clickedHandle) {
			clickedHandle = null;
		} else {
		}
	};

	element.addEventListener("mousedown", markElementHandle);
	element.addEventListener("touchstart", markElementHandle);

	element.addEventListener("dragstart", (event) => {
		const target = event.target;

		const h = target.querySelector("[data-monster-role='handle']");
		if (h instanceof HTMLElement) {
			if (!clickedHandle) {
				event.preventDefault();
				return;
			}
		}

		event.dataTransfer.setData("text/plain", event.target.id);
		dragInfo.element = event.target;
		event.target.style.opacity = "0.1";
		event.target.classList.add("dragging");

		const computedStyle = window.getComputedStyle(event.target);
		const originalCol =
			parseInt(computedStyle.getPropertyValue("grid-column-start"), 10) - 1;
		const originalRow =
			parseInt(computedStyle.getPropertyValue("grid-row-start"), 10) - 1;
		dragInfo.originalCell = { row: originalRow, col: originalCol };
	});

	element.addEventListener("dragend", (event) => {
		event.target.classList.remove("dragging");
		event.target.style.opacity = "1";
		dragInfo.element = undefined;
	});

	//let currentDropOverCell = null;
	element.addEventListener("dragover", function (event) {
		event.preventDefault();
		const gridInfo = getGridInfo(this);
		const cell = getDropTargetCell(this, event, gridInfo);

		const occupant = getElementAtCell.call(self, this, cell);
		if (occupant && occupant !== dragInfo.dragable) {
			shiftElement.call(self, occupant, this);
		}
	});

	return this;
}

/**
 * Ordnet in der angegebenen Zeile alle Elemente neu, sodass Lücken geschlossen werden.
 *
 * @param {HTMLElement} gridContainer - Das Grid-Element.
 * @param {number} row - Der 0-basierte Index der Zeile.
 * @param {Object} gridInfo - Informationen zur Grid-Struktur (z.B. Anzahl der Spalten).
 * @param {string} mode - "left" oder "right".
 */
function rebalanceRow(gridContainer, row, gridInfo, mode) {
	const children = Array.from(getSlottedElements.call(this, "div"));
	let items = [];

	children.forEach((elem) => {
		if (elem.classList.contains("dragging")) return;
		const computedStyle = window.getComputedStyle(elem);
		const rowStart =
			parseInt(computedStyle.getPropertyValue("grid-row-start"), 10) - 1;
		if (rowStart === row) {
			const colStart =
				parseInt(computedStyle.getPropertyValue("grid-column-start"), 10) - 1;
			items.push({ element: elem, col: colStart });
		}
	});

	if (mode === "left") {
		items.sort((a, b) => a.col - b.col);
		items.forEach((item, index) => {
			item.element.style.gridColumn = `${index + 1} / span 1`;
		});
	} else if (mode === "right") {
		items.sort((a, b) => b.col - a.col);
		const totalCols = gridInfo.columns.length;
		items.forEach((item, index) => {
			item.element.style.gridColumn = `${totalCols - index} / span 1`;
		});
	}
}

/**
 * Ordnet in der angegebenen Spalte alle Elemente neu, sodass Lücken geschlossen werden.
 *
 * @param {HTMLElement} gridContainer - Das Grid-Element.
 * @param {number} column - Der 0-basierte Index der Spalte.
 * @param {Object} gridInfo - Informationen zur Grid-Struktur (z.B. Anzahl der Zeilen).
 * @param {string} mode - "top" oder "bottom".
 */
function rebalanceColumn(gridContainer, column, gridInfo, mode) {
	const children = Array.from(getSlottedElements.call(this, "div"));
	let items = [];

	children.forEach((elem) => {
		if (elem.classList.contains("dragging")) return;
		const computedStyle = window.getComputedStyle(elem);
		const colStart =
			parseInt(computedStyle.getPropertyValue("grid-column-start"), 10) - 1;
		if (colStart === column) {
			const rowStart =
				parseInt(computedStyle.getPropertyValue("grid-row-start"), 10) - 1;
			items.push({ element: elem, row: rowStart });
		}
	});

	if (mode === "top") {
		items.sort((a, b) => a.row - b.row);
		items.forEach((item, index) => {
			item.element.style.gridRow = `${index + 1} / span 1`;
		});
	} else if (mode === "bottom") {
		items.sort((a, b) => b.row - a.row);
		const totalRows = gridInfo.rows.length;
		items.forEach((item, index) => {
			item.element.style.gridRow = `${totalRows - index} / span 1`;
		});
	}
}

/**
 * Finds an empty cell in the grid based on the specified mode.
 *
 * @param {HTMLElement} gridContainer - The container element of the grid.
 * @param {Object} cell - The current cell position with row and column properties.
 * @param {Object} gridInfo - Information about the grid including rows and columns arrays.
 * @param {HTMLElement} ignoreElement - An element to be ignored during the search for an empty cell.
 * @param {string} mode - The search mode, determining how to find the empty cell.
 *                        Possible values: "top", "bottom", "left", "right", "none".
 * @return {Object} The position of the empty cell with 'row' and 'col' properties.
 */
function findEmptyCellInMode(
	gridContainer,
	cell,
	gridInfo,
	ignoreElement,
	mode,
) {
	switch (mode) {
		case "top":
			// Suche in der Spalte von oben nach unten
			for (let row = 0; row < gridInfo.rows.length; row++) {
				const target = { row, col: cell.col };
				const occupant = getElementAtCell.call(this, gridContainer, target);
				if (!occupant || occupant === ignoreElement) {
					return target;
				}
			}
			return { row: 0, col: cell.col };

		case "bottom":
			for (let row = gridInfo.rows.length - 1; row >= 0; row--) {
				const target = { row, col: cell.col };
				const occupant = getElementAtCell.call(this, gridContainer, target);
				if (!occupant || occupant === ignoreElement) {
					return target;
				}
			}
			return { row: gridInfo.rows.length - 1, col: cell.col };

		case "left":
			for (let col = 0; col < gridInfo.columns.length; col++) {
				const target = { row: cell.row, col: col };
				const occupant = getElementAtCell.call(this, gridContainer, target);
				if (!occupant || occupant === ignoreElement) {
					return target;
				}
			}
			return { row: cell.row, col: 0 };

		case "right":
			for (let col = gridInfo.columns.length - 1; col >= 0; col--) {
				const target = { row: cell.row, col: col };
				const occupant = getElementAtCell.call(this, gridContainer, target);
				if (!occupant || occupant === ignoreElement) {
					return target;
				}
			}
			return { row: cell.row, col: gridInfo.columns.length - 1 };

		case "none":
		default:
			return cell;
	}
}

/**
 * Retrieves grid layout information from a grid container element, including the column and row sizes, and the gaps.
 *
 * @private
 * @param {Element} gridContainer The DOM element representing the grid container.
 * @return {Object} An object containing the grid's columns, rows, column gap, and row gap:
 * - `columns`: An array of numbers representing the width of each column.
 * - `rows`: An array of numbers representing the height of each row.
 * - `columnGap`: A number representing the gap size between columns.
 * - `rowGap`: A number representing the gap size between rows.
 */
function getGridInfo(gridContainer) {
	const style = window.getComputedStyle(gridContainer);

	const columns = style
		.getPropertyValue("grid-template-columns")
		.split(/\s+/)
		.map((val) => parseFloat(val));
	const rows = style
		.getPropertyValue("grid-template-rows")
		.split(/\s+/)
		.map((val) => parseFloat(val));

	const columnGap = parseFloat(style.getPropertyValue("column-gap")) || 0;
	const rowGap = parseFloat(style.getPropertyValue("row-gap")) || 0;

	return { columns, rows, columnGap, rowGap };
}

/**
 * Determines the drop target cell in a grid container based on the mouse event coordinates.
 *
 * @private
 * @param {HTMLElement} gridContainer - The DOM element representing the grid container.
 * @param {MouseEvent} event - The mouse event that contains the coordinates of the drop action.
 * @param {Object} gridInfo - An object containing grid layout information.
 * @param {number[]} gridInfo.columns - An array of column widths in the grid.
 * @param {number[]} gridInfo.rows - An array of row heights in the grid.
 * @param {number} gridInfo.columnGap - The gap size between columns in the grid.
 * @param {number} gridInfo.rowGap - The gap size between rows in the grid.
 * @return {Object} An object containing the row and column indices of the drop target cell.
 * @return {number} return.row - The index of the row in which the drop occurred. Returns -1 if no valid row is found.
 * @return {number} return.col - The index of the column in which the drop occurred. Returns -1 if no valid column is found.
 */
function getDropTargetCell(gridContainer, event, gridInfo) {
	const rect = gridContainer.getBoundingClientRect();
	const x = event.clientX - rect.left; // relative X position
	const y = event.clientY - rect.top; // relative Y position

	let currentX = 0;
	let colIndex = -1;
	for (let i = 0; i < gridInfo.columns.length; i++) {
		const colWidth = gridInfo.columns[i];
		if (x >= currentX && x < currentX + colWidth) {
			colIndex = i;
			break;
		}
		currentX += colWidth + gridInfo.columnGap;
	}

	let currentY = 0;
	let rowIndex = -1;
	for (let i = 0; i < gridInfo.rows.length; i++) {
		const rowHeight = gridInfo.rows[i];
		if (y >= currentY && y < currentY + rowHeight) {
			rowIndex = i;
			break;
		}
		currentY += rowHeight + gridInfo.rowGap;
	}

	return { row: rowIndex, col: colIndex };
}

function initGrid() {
	//const element = this[boardElementSymbol];

	const dimensions = this.getOption("dimensions");

	let stylesheet = "";
	for (const [key, value] of Object.entries(dimensions)) {
		stylesheet += `@container board (min-width: ${key}px) {
          [data-monster-role="grid"] {
            grid-template-columns: repeat(${value.columns}, 1fr);
            grid-template-rows: repeat(${value.rows}, 1fr);
          }
        }
        `;
	}

	const styleSheet = new CSSStyleSheet();
	styleSheet.replaceSync(stylesheet);

	this.shadowRoot.adoptedStyleSheets = [
		...Board.getCSSStyleSheet(),
		styleSheet,
	];

	return this;
}

/**
 * @private
 * @return {void}
 */
function initControlReferences() {
	this[boardElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="control"]`,
	);

	this[gridElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="grid"]`,
	);

	this[parkingElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="parking"]`,
	);
}

/**
 * Retrieves the element located at the specified cell within a grid container.
 *
 * @private
 * @param {HTMLElement} gridContainer - The container element representing the CSS grid.
 * @param {{col: number, row: number}} cell - An object containing the zero-based column (col) and row (row) indices of the desired cell.
 * @return {HTMLElement|null} The element at the specified cell, or null if no element exists at the given cell coordinates.
 */
function getElementAtCell(gridContainer, cell) {
	/** @var {Set<HTMLElement>} */
	const children = getSlottedElements.call(this, "div").values();

	for (const elem of children) {
		// Überspringe das Element, wenn es gerade gezogen wird
		if (elem.classList.contains("dragging")) {
			continue;
		}

		// Fetch the computed styles for the current element
		const computedStyle = window.getComputedStyle(elem);
		let colValue = computedStyle.getPropertyValue("grid-column-start");
		let rowValue = computedStyle.getPropertyValue("grid-row-start");

		let gridColumn = computedStyle.getPropertyValue("grid-column"); // z. B. "1 / span 1"
		let colX = parseInt(gridColumn.split("/")[0], 10) - 1;

		if (colValue === "auto" || rowValue === "auto") {
			continue;
		}

		const col = parseInt(colValue, 10) - 1; // 0-basierte Indizes
		const row = parseInt(rowValue, 10) - 1;

		if (col === cell.col && row === cell.row) {
			return elem;
		}
	}
	return null;
}

/**
 * Adjusts the position of a given cell within a grid based on a default shifting algorithm.
 * Tries to move the cell downwards first. If not possible, it attempts to move right,
 * then left. If none of these movements are possible, it returns the original cell position.
 *
 * @param {Object} currentCell - The current position of the cell.
 * @param {number} currentCell.row - The row index of the cell.
 * @param {number} currentCell.col - The column index of the cell.
 * @param {Object} gridInfo - Information about the grid structure.
 * @param {Array} gridInfo.rows - The rows of the grid.
 * @param {Array} gridInfo.columns - The columns of the grid.
 * @return {Object} An object containing the new cell position with `row` and `col` properties.
 */
function defaultShiftAlgorithm(currentCell, gridInfo) {
	// try first to move down
	let newRow = currentCell.row + 1;
	if (newRow < gridInfo.rows.length) {
		return { row: newRow, col: currentCell.col };
	}
	// if not possible, try to move right
	let newCol = currentCell.col + 1;
	if (newCol < gridInfo.columns.length) {
		return { row: currentCell.row, col: newCol };
	}
	// if not possible, try to move left
	newCol = currentCell.col - 1;
	if (newCol >= 0) {
		return { row: currentCell.row, col: newCol };
	}
	// finally, return the original cell position
	return currentCell;
}

/**
 * Shifts an element within a CSS grid container to a new position based on the specified algorithm.
 *
 * @param {HTMLElement} element - The element to be shifted within the grid container.
 * @param {HTMLElement} gridContainer - The grid container that houses the element.
 * @param {Function} [shiftAlgorithm=defaultShiftAlgorithm] - A function defining the shifting logic, which takes the current
 *        position and the grid information and returns the new position. Defaults to `defaultShiftAlgorithm` if not provided.
 * @return {void} No return value.
 */
function shiftElement(
	element,
	gridContainer,
	shiftAlgorithm = defaultShiftAlgorithm,
) {
	if (element.classList.contains("dragging")) {
		return;
	}

	const gridInfo = getGridInfo(gridContainer);

	const currentCol = parseInt(element.style.gridColumn.split("/")[0], 10) - 1;
	const currentRow = parseInt(element.style.gridRow.split("/")[0], 10) - 1;
	const currentCell = { row: currentRow, col: currentCol };

	if (element.dataset.originalPosition) {
		const orig = JSON.parse(element.dataset.originalPosition);
		const occupantAtOrig = getElementAtCell.call(this, gridContainer, orig);
		if (
			(!occupantAtOrig || occupantAtOrig === element) &&
			(currentCell.row !== orig.row || currentCell.col !== orig.col)
		) {
			element.style.gridColumn = `${orig.col + 1} / span 1`;
			element.style.gridRow = `${orig.row + 1} / span 1`;
			delete element.dataset.originalPosition;
			return;
		}
		if (currentCell.row === orig.row && currentCell.col === orig.col) {
			delete element.dataset.originalPosition;
		}
	} else {
		element.dataset.originalPosition = JSON.stringify(currentCell);
	}

	const newCell = shiftAlgorithm(currentCell, gridInfo);
	if (newCell.row === currentCell.row && newCell.col === currentCell.col) {
		return;
	}

	const occupant = getElementAtCell.call(this, gridContainer, newCell);
	if (occupant) {
		occupant.style.gridColumn = `${currentCell.col + 1} / span 1`;
		occupant.style.gridRow = `${currentCell.row + 1} / span 1`;
		delete occupant.dataset.originalPosition;
	}
	element.style.gridColumn = `${newCell.col + 1} / span 1`;
	element.style.gridRow = `${newCell.row + 1} / span 1`;
}

/**
 * Moves an HTML element to a specific cell in a grid layout.
 *
 * @param {HTMLElement} element - The HTML element to be positioned within the grid.
 * @param {{row: number, col: number}} cell - An object specifying the target cell's row and column indices.
 * @return {void}
 */
function moveElementToCell(element, cell) {
	element.style.gridColumn = `${cell.col + 1} / span 1`;
	element.style.gridRow = `${cell.row + 1} / span 1`;
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <div data-monster-role="parking"></div>
        <div data-monster-role="control" part="control">
            <div data-monster-role="grid" part="grid">
                <slot></slot>
            </div>`;
}

registerCustomElement(Board);
