GridElement.java

package org.vaadin.addons.dramafinder.element;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import org.vaadin.addons.dramafinder.element.shared.FocusableElement;
import org.vaadin.addons.dramafinder.element.shared.HasEnabledElement;
import org.vaadin.addons.dramafinder.element.shared.HasStyleElement;
import org.vaadin.addons.dramafinder.element.shared.HasThemeElement;

import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;

/**
 * PlaywrightElement for {@code <vaadin-grid>}.
 * <p>
 * Provides helpers for scrolling, querying visible rows, accessing cell
 * content, and interacting with the grid's header, body and selection.
 * Cell access uses JavaScript evaluation on the grid's internal APIs
 * since body cells live in shadow DOM and are virtualized.
 */
@PlaywrightElement(GridElement.FIELD_TAG_NAME)
public class GridElement extends VaadinElement
        implements FocusableElement, HasStyleElement, HasThemeElement, HasEnabledElement {

    public static final String FIELD_TAG_NAME = "vaadin-grid";

    /**
     * Create a new {@code GridElement}.
     *
     * @param locator the locator for the {@code <vaadin-grid>} element
     */
    public GridElement(Locator locator) {
        super(locator);
    }

    // ── Static Factory Methods ─────────────────────────────────────────

    /**
     * Get the first {@code GridElement} on the page.
     *
     * @param page the Playwright page
     * @return the first matching {@code GridElement}
     */
    public static GridElement get(Page page) {
        return new GridElement(page.locator(FIELD_TAG_NAME).first());
    }

    /**
     * Get the first {@code GridElement} within a parent locator.
     *
     * @param parent the parent locator to search within
     * @return the first matching {@code GridElement}
     */
    public static GridElement get(Locator parent) {
        return new GridElement(parent.locator(FIELD_TAG_NAME).first());
    }

    /**
     * Get a {@code GridElement} by its {@code id} attribute.
     *
     * @param page the Playwright page
     * @param id   the element id
     * @return the matching {@code GridElement}
     */
    public static GridElement getById(Page page, String id) {
        return new GridElement(page.locator("#" + id));
    }

    // ── Properties / Getters ───────────────────────────────────────────

    /**
     * Get the number of rows currently rendered in the DOM.
     * This may be less than the total row count due to virtualization.
     *
     * @return count of rows currently rendered in the DOM
     */
    public int getRenderedRowCount() {
        return (Integer) locator.elementHandle().evaluate("e => e._getRenderedRows().length");
    }

    /**
     * Get the total number of rows (data items) in the grid.
     *
     * @return total item count
     */
    public int getTotalRowCount() {
        return (int) locator.elementHandle().evaluate("e => e._flatSize");
    }

    /**
     * Get the number of visible (non-hidden) columns.
     *
     * @return visible column count
     */
    public int getColumnCount() {
        return ((Number) locator.evaluate(
                "el => el._getColumns().filter(c => !c.hidden).length")).intValue();
    }

    /**
     * Whether the grid has {@code allRowsVisible} enabled.
     *
     * @return {@code true} if all rows are visible (no vertical scroll)
     */
    public boolean isAllRowsVisible() {
        return Boolean.TRUE.equals(getProperty("allRowsVisible"));
    }

    /**
     * Whether the grid has multi-sort enabled.
     *
     * @return {@code true} if multi-sort is enabled
     */
    public boolean isMultiSort() {
        return Boolean.TRUE.equals(getProperty("multiSort"));
    }

    /**
     * Whether column reordering is allowed.
     *
     * @return {@code true} if column reordering is allowed
     */
    public boolean isColumnReorderingAllowed() {
        return Boolean.TRUE.equals(getProperty("columnReorderingAllowed"));
    }

    // ── Header Access ──────────────────────────────────────────────────


    /**
     * Get the number of header rows. Usually 1, but can be more if there are column groups.
     *
     * @return header row count
     */
    protected int getHeaderRowCount() {
        int rowCount = 0;
        var lastHeaderRow = locator.locator("thead tr.last-header-row").first();
        if (lastHeaderRow.count() > 0) {
            var headerRowIndex = lastHeaderRow.getAttribute("aria-rowIndex");
            if (headerRowIndex != null) {
                rowCount = Integer.parseInt(headerRowIndex);
            }
        }
        return rowCount;
    }

    /**
     * Find a header cell by column index.
     * Uses first header row.
     *
     * @param columnIndex 0-based visible column index
     * @return optional header cell element. Empty if no header cell exists at the given column index.
     */
    public Optional<HeaderCellElement> findHeaderCell(int columnIndex) {
        return findHeaderCell(0, columnIndex);
    }

    /**
     * Find a header cell by header row index and column index.
     *
     * @param headerRowIndex 0-based header row index. Use 0 for the first header row, 1 for the second, etc.
     * @param columnIndex    0-based visible column index.
     * @return optional header cell element. Empty if no header cell exists at the given header row and column index.
     */
    public Optional<HeaderCellElement> findHeaderCell(int headerRowIndex, int columnIndex) {
        if (columnIndex < 0) {
            throw new IllegalArgumentException("Column index must be non-negative");
        } else if (headerRowIndex < 0) {
            throw new IllegalArgumentException("Header row index must be non-negative");
        }

        var lastHeaderRow = locator.locator("thead tr").nth(headerRowIndex);
        if (lastHeaderRow.count() == 0) {
            return Optional.empty();
        }

        var headerCell = lastHeaderRow.locator("th").nth(columnIndex);
        if (headerCell.count() > 0) {
            headerCell.scrollIntoViewIfNeeded(); // Scroll into view, to make sure its rendered
            return Optional.of(new HeaderCellElement(headerCell, columnIndex));
        }

        return Optional.empty();
    }

    /**
     * Find a header cell by its text content.
     * Uses first header row.
     *
     * @param text the header text to find
     * @return optional header cell element. Empty if no header cell with the given text is found.
     */
    public Optional<HeaderCellElement> findHeaderCellByText(String text) {
        return findHeaderCellByText(0, text);
    }

    /**
     * Find a header cell by header row index and text content.
     *
     * @param headerRowIndex 0-based header row index. Use 0 for the first header row, 1 for the second, etc.
     * @param text           the header text to find
     * @return optional header cell element. Empty if no header cell with the given text is found in the given header row.
     */
    public Optional<HeaderCellElement> findHeaderCellByText(int headerRowIndex, String text) {
        if (text == null || text.isEmpty()) {
            throw new IllegalArgumentException("Text must not be null or empty");
        }
        var lastHeaderRow = locator.locator("thead tr").nth(headerRowIndex);
        if (lastHeaderRow.count() == 0) {
            return Optional.empty();
        }

        var allHeaderCells = lastHeaderRow.locator("th").all();
        for (int i = 0; i < allHeaderCells.size(); i++) {
            var cell = new HeaderCellElement(allHeaderCells.get(i), i);
            cell.getTableCellLocator().scrollIntoViewIfNeeded(); // Scroll into view, to make sure its rendered
            if (matchesHeaderCell(cell, text)) {
                return Optional.of(cell);
            }
        }

        return Optional.empty();
    }

    /**
     * Get the text content of all visible header cells.
     *
     * @return list of header cell text contents
     */
    public List<String> getHeaderCellContents() {
        int count = getColumnCount();
        List<String> headers = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            var headerCell = findHeaderCell(i);
            if (!headerCell.isPresent()) {
                throw new IllegalStateException("No header cell found at column index " + i);
            }
            headers.add(headerCell.get().getCellContentLocator().innerText());
        }
        return headers;
    }

    /**
     * Determine if a header cell matches the given text. By default, compares the trimmed innerText of the cell content.
     * Can be overridden for custom matching logic (e.g. ignoring case, or matching only a part of the text).
     *
     * @param cell the header cell to check
     * @param text the header text to match
     * @return {@code true} if the cell matches the text, {@code false} otherwise
     */
    protected boolean matchesHeaderCell(CellElement cell, String text) {
        return Objects.equals(cell.getCellContentLocator().innerText(), text);
    }

    // ── Cell Content Access ────────────────────────────────────────────

    /**
     * Find a body cell by row index and column index.
     *
     * @param row    row index, starting from 0.
     * @param column column index, starting from 0.
     * @return optional cell element. Empty if no cell exists at the given row and column index.
     */
    public Optional<CellElement> findCell(int row, int column) {
        if (row < 0 || column < 0) {
            throw new IllegalArgumentException("Row and column must be non-negative");
        }

        var rowLocatorOptional = findRow(row);
        if (!rowLocatorOptional.isPresent()) {
            return Optional.empty();
        }

        return Optional.of(rowLocatorOptional.get().getCell(column));
    }

    /**
     * Find a body cell by row index and column header text.
     *
     * @param row              row index, starting from 0.
     * @param columnHeaderText the header text of the column to find.
     * @return optional cell element. Empty if no cell exists at the given row and column header text.
     */
    public Optional<CellElement> findCell(int row, String columnHeaderText) {
        if (row < 0) {
            throw new IllegalArgumentException("Row and column must be non-negative");
        }
        var foundHeaderCell = findHeaderCellByText(columnHeaderText);
        if (!foundHeaderCell.isPresent()) {
            return Optional.empty();
        }

        var rowLocatorOptional = findRow(row);
        if (!rowLocatorOptional.isPresent()) {
            return Optional.empty();
        }

        return findCell(row, foundHeaderCell.get().getColumnIndex());
    }

    /**
     * Find row indexes where the cell in the given column has the given text.
     *
     * @param columnIndex the column index to check the cell content in, starting from 0.
     * @param text        the text to match in the cell content.
     * @return list of row indexes where the cell in the given column matches the given text. Empty list if no match is found.
     */
    public List<Integer> findRowIndexesWithColumnText(int columnIndex, String text) {
        if (text == null) {
            throw new IllegalArgumentException("Text must not be null");
        }

        var headerRowCount = getHeaderRowCount();
        var matchingRows = new ArrayList<Integer>();
        int totalRowCount = getTotalRowCount();
        for (int i = 0; i < totalRowCount; i++) {
            var rowOptional = findRow(i, headerRowCount);

            rowOptional.ifPresent(row -> {
                var cell = row.getCell(columnIndex);
                if (matchesCellContent(cell, text)) {
                    matchingRows.add(row.getRowIndex());
                }
            });
        }

        return matchingRows;
    }

    /**
     * Find a row by its index.
     * Scrolls the grid if necessary to find the row.
     *
     * @param rowIndex row index, starting from 0.
     * @return optional row element. Empty if no row exists at the given index.
     */
    public Optional<RowElement> findRow(int rowIndex) {
        if (rowIndex < 0) {
            throw new IllegalArgumentException("Row index must be non-negative");
        }

        return findRow(rowIndex, getHeaderRowCount());
    }

    /**
     * Find a row by its index, given the number of header rows.
     *
     * @param rowIndex       row index, starting from 0.
     * @param headerRowCount number of header rows in the grid, used to calculate the aria-rowIndex for finding the row.
     * @return optional row element. Empty if no row exists at the given index.
     */
    protected Optional<RowElement> findRow(int rowIndex, int headerRowCount) {
        var ariaRowIndex = rowIndex + 1 + headerRowCount;
        // Attempt to find the row directly
        var foundRow = locator.locator("tbody tr[aria-rowIndex=\"" + ariaRowIndex + "\"]").first();
        if (foundRow.count() == 0) {
            // Row not found, try scrolling to it
            var foundRowByScrolling = findRowByScrolling(locator, ariaRowIndex);
            if (foundRowByScrolling.isEmpty()) {
                return Optional.empty();
            } else {
                foundRow = foundRowByScrolling.get();
            }
        } else {
            foundRow.scrollIntoViewIfNeeded();
            waitForGridToStopLoading();
        }

        return Optional.of(new RowElement(foundRow, rowIndex));
    }

    private Optional<Locator> findRowByScrolling(Locator grid, int ariaRowIndex) {
        return findRowByScrolling(grid, null, ariaRowIndex);
    }

    private Optional<Locator> findRowByScrolling(Locator grid, RowRangeData previousRowRangeData, int ariaRowIndex) {
        var visibleRows = grid.locator("tbody tr").all();
        if (visibleRows.isEmpty()) {
            return Optional.empty();
        }

        var rowRangeData = new RowRangeData(visibleRows);

        if (!areNewRowsLoaded(previousRowRangeData, rowRangeData, ariaRowIndex)) {
            return Optional.empty();
        }

        if (ariaRowIndex < rowRangeData.getMin()) {
            // Scroll up
            rowRangeData.getMinRowLocator().evaluate("el => el.scrollIntoView({ block: 'end', inline: 'nearest' })");
        } else {
            // Scroll down
            rowRangeData.getMaxRowLocator().evaluate("el => el.scrollIntoView({ block: 'start', inline: 'nearest' })");
        }

        waitForGridToStopLoading();

        // Attempt to find the required row after scrolling
        var foundRow = grid.locator("tbody tr[aria-rowIndex=\"" + ariaRowIndex + "\"]").first();
        if (foundRow.count() == 0) {
            // Keep scrolling
            return findRowByScrolling(grid, rowRangeData, ariaRowIndex);
        } else {
            foundRow.scrollIntoViewIfNeeded();
            waitForGridToStopLoading();
        }

        return Optional.of(foundRow);
    }

    private static boolean areNewRowsLoaded(RowRangeData previousRowRangeData, RowRangeData currentRowRangeData, int targetAriaRowIndex) {
        if (previousRowRangeData == null) {
            return true;
        }

        // Check if the current row range has expanded in the direction of the target row index
        if (targetAriaRowIndex < previousRowRangeData.getMin()) {
            return currentRowRangeData.getMin() < previousRowRangeData.getMin();
        } else {
            return currentRowRangeData.getMax() > previousRowRangeData.getMax();
        }
    }

    /**
     * Determine if a body cell matches the given text. By default, compares the trimmed innerText of the cell content.
     * Can be overridden for custom matching logic (e.g. ignoring case, or matching only a part of the text).
     *
     * @param cell the body cell to check
     * @param text the text to match in the cell content
     * @return {@code true} if the cell matches the text, {@code false} otherwise
     */
    protected boolean matchesCellContent(CellElement cell, String text) {
        cell.getCellContentLocator().scrollIntoViewIfNeeded(); // Scroll into view, to make sure its rendered
        return Objects.equals(cell.getCellContentLocator().innerText(), text);
    }

    // ── Scroll Actions ─────────────────────────────────────────────────

    /**
     * Scroll the grid so that the given row index becomes visible.
     * Waits for the row to be rendered in the DOM before returning.
     *
     * @param rowIndex the 0-based row index to scroll to
     */
    public void scrollToRow(int rowIndex) {
        var row = findRow(rowIndex);
        if (row.isPresent()) {
            row.get().getRowLocator().scrollIntoViewIfNeeded();
            waitForGridToStopLoading();
        }
    }

    /**
     * Scroll to the very beginning of the grid.
     */
    public void scrollToStart() {
        scrollToRow(0);
    }

    /**
     * Scroll to the very end of the grid.
     */
    public void scrollToEnd() {
        var row = findRow(getTotalRowCount() - 1);
        if (row.isPresent()) {
            row.get().getRowLocator().scrollIntoViewIfNeeded();
            waitForGridToStopLoading();
        }
    }

    /**
     * Select a row by id.
     *
     * @param rowIndex index of the row to select, starting from 0.
     */
    public void select(int rowIndex) {
        var rowOptional = findRow(rowIndex);
        rowOptional.ifPresent(this::select);
    }

    /**
     * Select the row.
     * You can override this method to implement custom selection logic,
     * for example if you want to select by clicking some other cell than the first one,
     * or if you want to use some modifier keys for selection.
     *
     * @param row row to select.
     */
    protected void select(RowElement row) {
        if (!row.isSelected()) {
            clickCellForSelection(row.getCell(0));
        }
    }

    /**
     * Deselect a row by id.
     *
     * @param rowIndex index of the row to deselect, starting from 0.
     */
    public void deselect(int rowIndex) {
        var rowOptional = findRow(rowIndex);
        rowOptional.ifPresent(this::deselect);
    }

    /**
     * Deselect the row.
     * You can override this method to implement custom selection logic,
     * for example if you want to deselect by clicking some other cell than the first one,
     * or if you want to use some modifier keys for deselection.
     *
     * @param row row to deselect.
     */
    protected void deselect(RowElement row) {
        if (row.isSelected()) {
            clickCellForSelection(row.getCell(0));
        }
    }

    /**
     * You can override this method to implement custom selection logic for given cell.
     */
    protected void clickCellForSelection(CellElement cell) {
        var checkbox = cell.getCellContentLocator().locator("vaadin-checkbox");
        if (checkbox.count() > 0) {
            checkbox.click();
        } else {
            cell.click();
        }
        waitForGridToStopLoading();
    }

    /**
     * Get the number of currently selected items.
     *
     * @return selected item count
     */
    public int getSelectedItemCount() {
        return ((Number) getLocator().evaluate(
                "el => el.selectedItems ? el.selectedItems.length : 0")).intValue();
    }

    /**
     * Check if the select-all checkbox is checked.
     *
     * @return {@code true} if the select-all checkbox is checked (and not indeterminate), {@code false} otherwise
     */
    public boolean isSelectAllChecked() {
        var checkbox = getSelectAllCheckboxLocator();
        return checkbox.getAttribute("checked") != null
                && checkbox.getAttribute("indeterminate") == null;
    }

    /**
     * Check if the select-all checkbox is indeterminate.
     *
     * @return {@code true} if the select-all checkbox is indeterminate, {@code false} otherwise
     */
    public boolean isSelectAllIndeterminate() {
        return getSelectAllCheckboxLocator().getAttribute("indeterminate") != null;
    }

    /**
     * Check if the select-all checkbox is unchecked.
     *
     * @return {@code true} if the select-all checkbox is unchecked, {@code false} otherwise
     */
    public boolean isSelectAllUnchecked() {
        return getSelectAllCheckboxLocator().getAttribute("checked") == null;
    }

    /**
     * Check the select-all checkbox.
     * Does nothing if the select-all checkbox is already checked.
     */
    public void checkSelectAll() {
        if (isSelectAllChecked()) {
            return;
        }
        getSelectAllCheckboxLocator().click();
        waitForGridToStopLoading();
    }

    /**
     * Uncheck the select-all checkbox.
     * Does nothing if the select-all checkbox is already unchecked.
     */
    public void uncheckSelectAll() {
        if (isSelectAllUnchecked()) {
            return;
        }
        getSelectAllCheckboxLocator().click();
        waitForGridToStopLoading();
    }

    /**
     * Get the select-all checkbox element.
     *
     * @return the select-all checkbox element
     */
    private Locator getSelectAllCheckboxLocator() {
        var checkboxLocator = getLocator().locator("vaadin-checkbox.vaadin-grid-select-all-checkbox");
        if (checkboxLocator.count() == 0) {
            throw new IllegalStateException("Select-all checkbox not found in the grid header");
        }
        return checkboxLocator.first();
    }


    /**
     * Open details for a row by id.
     * Override this method if you need to control how the details are opened,
     * for example by clicking some other cell than the first one.
     *
     * @param row row for which to open details
     */
    protected void openDetails(RowElement row) {
        if (row.isDetailsOpen()) {
            return;
        }

        row.getCell(1).click();
        waitForGridToStopLoading();
    }

    /**
     * Close details for a row by id.
     * Override this method if you need to control how the details are closed,
     * for example by clicking some other cell than the first one.
     *
     * @param row row for which to close details
     */
    protected void closeDetails(RowElement row) {
        if (!row.isDetailsOpen()) {
            return;
        }

        row.getCell(1).click();
        waitForGridToStopLoading();
    }

    /**
     * Wait for the grid to finish loading after a scroll or other action that triggers loading of new rows.
     */
    public void waitForGridToStopLoading() {
        locator.page().waitForFunction("g => !g.hasAttribute('loading')", locator.elementHandle());
        locator.evaluate("g => g.updateComplete.then(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))");
    }


    /**
     * Whether the row with the given index is currently scrolled into view
     * (at least partially visible between the header and footer), without
     * triggering any scrolling.
     *
     * @param rowIndex row index, starting from 0.
     * @return {@code true} if the row is rendered and at least partially visible, {@code false} otherwise
     */
    public boolean isRowInView(int rowIndex) {
        if (rowIndex < 0) {
            throw new IllegalArgumentException("Row index must be non-negative");
        }
        var ariaRowIndex = rowIndex + 1 + getHeaderRowCount();
        var row = locator.locator("tbody tr[aria-rowIndex=\"" + ariaRowIndex + "\"]").first();
        if (row.count() == 0) {
            return false;
        }
        return (boolean) row.evaluate(
                "tr => { const grid = tr.getRootNode().host;"
                        + " const r = tr.getBoundingClientRect();"
                        + " const top = grid.$.header.getBoundingClientRect().bottom;"
                        + " const bottom = grid.$.footer.getBoundingClientRect().top;"
                        + " return r.bottom > top && r.top < bottom; }");
    }

    // ── Assertions ─────────────────────────────────────────────────────

    /**
     * Assert that the grid has the given total number of rows (data items).
     *
     * @param expected the expected total row count
     */
    public void assertRowCount(int expected) {
        locator.page().waitForCondition(() -> getTotalRowCount() == expected);
    }

    /**
     * Assert that the grid has no rows.
     */
    public void assertEmpty() {
        assertRowCount(0);
    }

    /**
     * Assert that the grid has the given number of visible (non-hidden) columns.
     *
     * @param expected the expected visible column count
     */
    public void assertColumnCount(int expected) {
        locator.page().waitForCondition(() -> getColumnCount() == expected);
    }

    /**
     * Assert that {@code allRowsVisible} is enabled.
     */
    public void assertAllRowsVisible() {
        locator.page().waitForCondition(this::isAllRowsVisible);
    }

    /**
     * Assert that {@code allRowsVisible} is not enabled.
     */
    public void assertNotAllRowsVisible() {
        locator.page().waitForCondition(() -> !isAllRowsVisible());
    }

    /**
     * Assert that multi-sort is enabled.
     */
    public void assertMultiSort() {
        locator.page().waitForCondition(this::isMultiSort);
    }

    /**
     * Assert that multi-sort is not enabled.
     */
    public void assertNotMultiSort() {
        locator.page().waitForCondition(() -> !isMultiSort());
    }

    /**
     * Assert that column reordering is allowed.
     */
    public void assertColumnReorderingAllowed() {
        locator.page().waitForCondition(this::isColumnReorderingAllowed);
    }

    /**
     * Assert that column reordering is not allowed.
     */
    public void assertColumnReorderingNotAllowed() {
        locator.page().waitForCondition(() -> !isColumnReorderingAllowed());
    }

    /**
     * Assert that the body cell at the given row and column has the given text content.
     * Auto-scrolls the grid if necessary to bring the row into view.
     *
     * @param row      row index, starting from 0.
     * @param column   column index, starting from 0.
     * @param expected the expected cell text content
     */
    public void assertCellContent(int row, int column, String expected) {
        var cell = findCell(row, column);
        if (!cell.isPresent()) {
            throw new AssertionError("No cell found at row " + row + ", column " + column);
        }
        assertThat(cell.get().getCellContentLocator()).hasText(expected);
    }

    /**
     * Assert that the body cell at the given row and column header has the given text content.
     * Auto-scrolls the grid if necessary to bring the row into view.
     *
     * @param row              row index, starting from 0.
     * @param columnHeaderText the header text of the column.
     * @param expected         the expected cell text content
     */
    public void assertCellContent(int row, String columnHeaderText, String expected) {
        var cell = findCell(row, columnHeaderText);
        if (!cell.isPresent()) {
            throw new AssertionError("No cell found at row " + row + ", column '" + columnHeaderText + "'");
        }
        assertThat(cell.get().getCellContentLocator()).hasText(expected);
    }

    /**
     * Assert that a cell exists at the given row and column.
     *
     * @param row    row index, starting from 0.
     * @param column column index, starting from 0.
     */
    public void assertCellPresent(int row, int column) {
        locator.page().waitForCondition(() -> cellExists(row, column));
    }

    /**
     * Assert that no cell exists at the given row and column.
     *
     * @param row    row index, starting from 0.
     * @param column column index, starting from 0.
     */
    public void assertCellNotPresent(int row, int column) {
        locator.page().waitForCondition(() -> !cellExists(row, column));
    }

    /**
     * Whether a cell exists at the given row and column. Unlike {@link #findCell(int, int)},
     * this treats an out-of-range column (which {@code findCell} reports by throwing) as "not present".
     */
    private boolean cellExists(int row, int column) {
        try {
            return findCell(row, column).isPresent();
        } catch (IllegalArgumentException e) {
            return false;
        }
    }

    /**
     * Assert that the visible header cells have exactly the given text contents, in order.
     *
     * @param expected the expected header cell text contents
     */
    public void assertHeaderCellContents(String... expected) {
        locator.page().waitForCondition(() -> getHeaderCellContents().equals(List.of(expected)));
    }

    /**
     * Assert that the header cell at the given column has the given text content.
     *
     * @param column   column index, starting from 0.
     * @param expected the expected header cell text content
     */
    public void assertHeaderCell(int column, String expected) {
        var headerCell = findHeaderCell(column);
        if (!headerCell.isPresent()) {
            throw new AssertionError("No header cell found at column " + column);
        }
        assertThat(headerCell.get().getCellContentLocator()).hasText(expected);
    }

    /**
     * Assert that a column with the given header text exists.
     *
     * @param headerText the header text to look for
     */
    public void assertColumnPresent(String headerText) {
        locator.page().waitForCondition(() -> findHeaderCellByText(headerText).isPresent());
    }

    /**
     * Assert that no column with the given header text exists.
     *
     * @param headerText the header text to look for
     */
    public void assertColumnNotPresent(String headerText) {
        locator.page().waitForCondition(() -> !findHeaderCellByText(headerText).isPresent());
    }

    /**
     * Assert that a row exists at the given index (auto-scrolling if necessary).
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertRowPresent(int rowIndex) {
        locator.page().waitForCondition(() -> findRow(rowIndex).isPresent());
    }

    /**
     * Assert that no row exists at the given index.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertRowNotPresent(int rowIndex) {
        locator.page().waitForCondition(() -> !findRow(rowIndex).isPresent());
    }

    /**
     * Assert that the row with the given index is currently scrolled into view.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertRowInView(int rowIndex) {
        locator.page().waitForCondition(() -> isRowInView(rowIndex));
    }

    /**
     * Assert that the row with the given index is not currently scrolled into view.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertRowNotInView(int rowIndex) {
        locator.page().waitForCondition(() -> !isRowInView(rowIndex));
    }

    /**
     * Assert that the cells in the given column with the given text appear at exactly the given row indexes.
     *
     * @param column   column index, starting from 0.
     * @param text     the text to match in the cell content.
     * @param expected the expected row indexes (in order)
     */
    public void assertRowIndexesWithColumnText(int column, String text, Integer... expected) {
        locator.page().waitForCondition(
                () -> findRowIndexesWithColumnText(column, text).equals(List.of(expected)));
    }

    /**
     * Assert that the given number of items are currently selected.
     *
     * @param expected the expected selected item count
     */
    public void assertSelectedItemCount(int expected) {
        locator.page().waitForCondition(() -> getSelectedItemCount() == expected);
    }

    /**
     * Assert that the row at the given index is selected.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertRowSelected(int rowIndex) {
        locator.page().waitForCondition(
                () -> findRow(rowIndex).map(RowElement::isSelected).orElse(false));
    }

    /**
     * Assert that the row at the given index is not selected.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertRowNotSelected(int rowIndex) {
        locator.page().waitForCondition(
                () -> findRow(rowIndex).map(row -> !row.isSelected()).orElse(false));
    }

    /**
     * Assert that the select-all checkbox is checked.
     */
    public void assertSelectAllChecked() {
        locator.page().waitForCondition(this::isSelectAllChecked);
    }

    /**
     * Assert that the select-all checkbox is unchecked.
     */
    public void assertSelectAllUnchecked() {
        locator.page().waitForCondition(this::isSelectAllUnchecked);
    }

    /**
     * Assert that the select-all checkbox is indeterminate.
     */
    public void assertSelectAllIndeterminate() {
        locator.page().waitForCondition(this::isSelectAllIndeterminate);
    }

    /**
     * Assert that the details panel of the row at the given index is open.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertDetailsOpen(int rowIndex) {
        locator.page().waitForCondition(
                () -> findRow(rowIndex).map(RowElement::isDetailsOpen).orElse(false));
    }

    /**
     * Assert that the details panel of the row at the given index is closed.
     *
     * @param rowIndex row index, starting from 0.
     */
    public void assertDetailsClosed(int rowIndex) {
        locator.page().waitForCondition(
                () -> findRow(rowIndex).map(row -> !row.isDetailsOpen()).orElse(false));
    }

    private class RowRangeData {
        Integer min;
        Locator minRow;
        Integer max;
        Locator maxRow;

        public RowRangeData(List<Locator> rows) {
            for (var row : rows) {
                var rowIndexStr = row.getAttribute("aria-rowIndex");
                if (rowIndexStr != null && !rowIndexStr.isEmpty()) {
                    int rowIndex = Integer.parseInt(rowIndexStr);
                    if (min == null || rowIndex < min) {
                        min = rowIndex;
                        minRow = row;
                    }
                    if (max == null || rowIndex > max) {
                        max = rowIndex;
                        maxRow = row;
                    }
                }
            }
        }

        public Integer getMin() {
            return min;
        }

        public Integer getMax() {
            return max;
        }

        public Locator getMinRowLocator() {
            return minRow;
        }

        public Locator getMaxRowLocator() {
            return maxRow;
        }
    }

    /**
     * Represents a cell in the grid, providing access to the table cell (td or th),
     * the cell content (vaadin-grid-cell-content) and the column index.
     */
    public class CellElement {
        private final Locator tableCell;
        private final int columnIndex;
        private final Locator cellContent;
        private final String contentSlotName;

        public CellElement(Locator tableCell, int columnIndex) {
            this.tableCell = tableCell;
            this.columnIndex = columnIndex;

            if (tableCell.count() == 0) {
                throw new IllegalArgumentException("Table cell locator is empty");
            }

            this.contentSlotName = tableCell.locator("slot").first().getAttribute("name");
            this.cellContent = locator.locator("vaadin-grid-cell-content[slot=\"" + contentSlotName + "\"]").first();
        }

        /**
         * Get the locator for the table cell (td or th).
         *
         * @return the locator for the table cell
         */
        public Locator getTableCellLocator() {
            return tableCell;
        }

        /**
         * Get the column index (0-based) of this cell.
         * Returns -1 for details cells.
         *
         * @return the column index
         */
        public int getColumnIndex() {
            return columnIndex;
        }

        /**
         * Get the locator for the cell content (vaadin-grid-cell-content) assigned to this cell.
         *
         * @return the locator for the cell content
         */
        public Locator getCellContentLocator() {
            return cellContent;
        }

        /**
         * Get the name of the slot used for the cell content. This is used for accessing the cell content in the grid's shadow DOM.
         *
         * @return the slot name
         */
        public String getContentSlotName() {
            return contentSlotName;
        }

        /**
         * Click the cell content.
         */
        public void click() {
            cellContent.click();
        }
    }

    /**
     * Represents a header cell in the grid, providing access to the table cell (th),
     * the cell content and sorting.
     */
    public class HeaderCellElement extends CellElement {
        public HeaderCellElement(Locator tableCell, int columnIndex) {
            super(tableCell, columnIndex);
        }

        /**
         * Whether the header cell supports sorting.
         *
         * @return {@code true} if the header cell supports sorting, {@code false} otherwise
         */
        public boolean isSortable() {
            var sorterLocator = getCellContentLocator().locator("vaadin-grid-sorter");
            return sorterLocator.count() > 0;
        }

        /**
         * Click the header cell sorter to sort the column.
         * If the column is not currently sorted, it will be sorted in ascending order.
         */
        public void clickSort() {
            var sorterLocator = getSorterLocator();
            if (sorterLocator.count() == 0) {
                throw new IllegalStateException("Header cell at column index " + getColumnIndex() + " is not sortable");
            }
            sorterLocator.first().click();
            waitForGridToStopLoading();
        }

        /**
         * Whether the column is currently sorted in ascending order.
         *
         * @return {@code true} if the column is sorted in ascending order, {@code false} otherwise
         */
        public boolean isSortAscending() {
            return "asc".equals(getSortDirection());
        }

        /**
         * Whether the column is currently sorted in descending order.
         *
         * @return {@code true} if the column is sorted in descending order, {@code false} otherwise
         */
        public boolean isSortDescending() {
            return "desc".equals(getSortDirection());
        }

        /**
         * Whether the column is currently not sorted.
         *
         * @return {@code true} if the column is not sorted, {@code false} otherwise
         */
        public boolean isNotSorted() {
            var sortDirection = getSortDirection();
            return sortDirection == null || sortDirection.isEmpty();
        }

        /**
         * Assert that the column is sorted in ascending order.
         */
        public void assertSortAscending() {
            locator.page().waitForCondition(this::isSortAscending);
        }

        /**
         * Assert that the column is sorted in descending order.
         */
        public void assertSortDescending() {
            locator.page().waitForCondition(this::isSortDescending);
        }

        /**
         * Assert that the column is not sorted.
         */
        public void assertNotSorted() {
            locator.page().waitForCondition(this::isNotSorted);
        }

        /**
         * Assert that the header cell supports sorting.
         */
        public void assertSortable() {
            locator.page().waitForCondition(this::isSortable);
        }

        /**
         * Assert that the header cell does not support sorting.
         */
        public void assertNotSortable() {
            locator.page().waitForCondition(() -> !isSortable());
        }

        /**
         * Get the current sort direction of the column.
         * Returns "asc" for ascending, "desc" for descending, and null or empty string for not sorted.
         *
         * @return the sort direction, or null/empty if not sorted
         */
        private String getSortDirection() {
            var sorterLocator = getSorterLocator();
            if (sorterLocator.count() == 0) {
                throw new IllegalStateException("Header cell at column index " + getColumnIndex() + " is not sortable");
            }
            return sorterLocator.first().getAttribute("direction");
        }

        /**
         * Get the locator for the vaadin-grid-sorter element in this header cell, if it exists.
         *
         * @return the locator for the vaadin-grid-sorter element, or an empty locator if it doesn't exist
         */
        private Locator getSorterLocator() {
            return getCellContentLocator().locator("vaadin-grid-sorter");
        }
    }

    /**
     * Represents a row in the grid, providing access to the row locator,
     * row index, and methods for accessing cells and interacting with the row (selection, details).
     */
    public class RowElement {
        private final Locator row;
        private final int rowIndex;

        public RowElement(Locator rowLocator, int rowIndex) {
            if (rowIndex < 0) {
                throw new IllegalArgumentException("Row index must be non-negative");
            }

            this.row = rowLocator;
            this.rowIndex = rowIndex;
        }

        /**
         * Get the locator for the row (tr).
         *
         * @return the locator for the row
         */
        public Locator getRowLocator() {
            return row;
        }

        /**
         * Get the row index (0-based) of this row.
         *
         * @return the row index
         */
        public int getRowIndex() {
            return rowIndex;
        }

        /**
         * Get the cell element for the given column index in this row.
         *
         * @param columnIndex the column index (0-based) of the cell to get
         * @return the cell element for the given column index
         */
        public CellElement getCell(int columnIndex) {
            if (columnIndex < 0) {
                throw new IllegalArgumentException("Column index must be non-negative");
            }

            var column = row.locator("td").nth(columnIndex);
            if (column.count() == 0) {
                throw new IllegalArgumentException("Column with index " + columnIndex + " does not exist in the row " + rowIndex);
            }

            column.scrollIntoViewIfNeeded(); // Scroll into view, to make sure its rendered
            return new CellElement(column, columnIndex);
        }

        /**
         * Get the cell element for the given column header text in this row.
         *
         * @param columnHeaderText the text of the column header
         * @return the cell element for the given column header text
         */
        public CellElement getCell(String columnHeaderText) {
            if (columnHeaderText == null || columnHeaderText.isEmpty()) {
                throw new IllegalArgumentException("Column header text must not be null or empty");
            }
            var foundHeaderCell = findHeaderCellByText(columnHeaderText);
            if (!foundHeaderCell.isPresent()) {
                throw new IllegalArgumentException("Column with header text '" + columnHeaderText + "' does not exist");
            }

            return getCell(foundHeaderCell.get().getColumnIndex());
        }

        /**
         * Get the cell element for the details column in this row.
         *
         * @return the cell element for the details column.
         */
        public CellElement getDetailsCell() {
            var detailsCell = row.locator("td.details-cell").first();
            if (detailsCell.count() == 0) {
                throw new IllegalArgumentException("Details cell does not exist in the row " + rowIndex);
            }

            detailsCell.scrollIntoViewIfNeeded(); // Scroll into view, to make sure its rendered
            return new CellElement(detailsCell, -1);
        }

        /**
         * Whether this row is selected.
         *
         * @return true if the row is selected, false otherwise
         */
        public boolean isSelected() {
            return row.getAttribute("selected") != null;
        }

        /**
         * Select this row.
         */
        public void select() {
            GridElement.this.select(this);
        }

        /**
         * Deselect this row.
         */
        public void deselect() {
            GridElement.this.deselect(this);
        }

        /**
         * Whether the details for this row are open.
         * If you need to use a custom way to open the details,
         * override the {@link GridElement#openDetails(GridElement.Row)} method.
         */
        public void openDetails() {
            GridElement.this.openDetails(this);
        }

        /**
         * Close the details for this row.
         * If you need to use a custom way to close the details,
         * override the {@link GridElement#closeDetails(GridElement.Row)} method.
         */
        public void closeDetails() {
            GridElement.this.closeDetails(this);
        }

        /**
         * Whether the details for this row are open.
         *
         * @return true if the details are open, false otherwise
         */
        public boolean isDetailsOpen() {
            return getRowLocator().getAttribute("details-opened") != null;
        }

        /**
         * Assert that this row is selected.
         */
        public void assertSelected() {
            locator.page().waitForCondition(this::isSelected);
        }

        /**
         * Assert that this row is not selected.
         */
        public void assertNotSelected() {
            locator.page().waitForCondition(() -> !isSelected());
        }

        /**
         * Assert that this row's details panel is open.
         */
        public void assertDetailsOpen() {
            locator.page().waitForCondition(this::isDetailsOpen);
        }

        /**
         * Assert that this row's details panel is closed.
         */
        public void assertDetailsClosed() {
            locator.page().waitForCondition(() -> !isDetailsOpen());
        }
    }
}