/*!
 * Custom elements for Nanopore-Digital Web applications
 */
import { readTask, writeTask } from "@stencil/core";
import { CSSNAMESPACE } from "./table.constants";
/**
 * Manages the complex business of table 'pinning'; sticking columns and rows to the scrolling parent element.
 *
 * *knowing* when an element is pinned is tricky.
 * Managing the display of multiple, side-by-side pinned elements is trickier still.
 *
 * Pinning table columns are very different from pinning table rows:
 * - Rows are actual elements we can select, measure and apply styles to.
 * - Columns are disparate collections of elements that are much harder to select, measure and apply styles to.
 *
 * With this in mind, how columns and rows are pinned is different:
 * Rows can have changes applied directly, Columns have changes applied via dynamic stylesheets.
 *
 * The service is slightly opinionated on how it pins rows and columns (with some room for override):
 * - Pinned columns are stuck consecutively, without overlapping.
 * e.g. If column 'name' and 'surname' are both `pin: 'start'`; 'surname' will display **next** to name.
 * Both columns are important for context
 *
 * - Pinned rows are set to overlap *when* they have the same ancestor,
 * and stuck consecutively when they have a different ancestor.
 * e.g. `tbody > tr.pin ~ tr.pin` the second row will **overlap** the first; it's unlikely both rows will be important for context.
 * `thead > tr.pin`, `tbody > tr.pin`. Both rows are required for context so will require next to each other.
 *
 * Devs can override this behaviour by setting `--pin-start`, `--pin-end`, `--pin-top`, `--pin-bottom` custom vars.
 */
export class TablePinService {
    // Private state
    cachedColMeta = new WeakMap();
    tableId;
    tableEle;
    scrollElement;
    startColumns;
    endColumns;
    topRows;
    bottomRows;
    tableDims;
    constructor(table, scrollElement) {
        this.tableEle = table;
        this.tableId = this.tableEle.id;
        this.scrollElement = scrollElement;
        // Secret sauce - `getElementsByClassName` is a live collection.
        // An HTMLCollection will keep itself up-to-date as elements come and go
        // So we can keep assessing on scroll
        this.startColumns = table
            .querySelector('thead')
            .getElementsByClassName(`${CSSNAMESPACE}__pin--start`);
        this.endColumns = table
            .querySelector('thead')
            .getElementsByClassName(`${CSSNAMESPACE}__pin--end`);
        this.topRows = table.getElementsByClassName(`${CSSNAMESPACE}__pin--top`);
        this.bottomRows = table.getElementsByClassName(`${CSSNAMESPACE}__pin--bottom`);
        this.onResize();
    }
    // Pinned cols & change detection
    get pinnedStart() {
        return this._pinnedStart;
    }
    set pinnedStart(cols) {
        this._pinnedStart = cols;
        this.handlePinnedStartChange();
    }
    _pinnedStart = [];
    addStyleSheet(id, css) {
        const root = this.tableEle.getRootNode();
        const styleSheet = root.getElementById(id) ||
            document.createElement('style');
        styleSheet.id = id;
        styleSheet.innerHTML = css;
        if (!styleSheet.isConnected) {
            if (root.head) {
                root.head.append(styleSheet);
            }
            else {
                root.append(styleSheet);
            }
        }
    }
    /**
     * Called when columns are either pinned or unpinned.
     * Attaches a tiny stylesheet to target the 'last' start column.
     * (e.g. We only want to apply drop shadow on last pinned start column - not all pinned columns)
     */
    handlePinnedStartChange() {
        writeTask(() => {
            if (this.pinnedStart.length) {
                this.tableEle.classList.add(`${CSSNAMESPACE}__pinned--start`);
                const lastActiveCol = this.cachedColMeta.get(this.pinnedStart[this.pinnedStart.length - 1]);
                this.addStyleSheet(`${this.tableId}-col-start-active-style`, 
                /* css */ `
          #${this.tableId} tr > :nth-child(${lastActiveCol.idx + 1}) {
            --pin-start-active: 1;
          }
        `);
            }
            else {
                this.tableEle.classList.remove(`${CSSNAMESPACE}__pinned--start`);
                this.addStyleSheet(`${this.tableId}-col-start-active-style`, ``);
            }
        });
    }
    get pinnedEnd() {
        return this._pinnedEnd;
    }
    set pinnedEnd(cols) {
        this._pinnedEnd = cols;
        this.handlePinnedEndChange();
    }
    _pinnedEnd = [];
    /**
     * Called when columns are either pinned or unpinned.
     * Attaches a tiny stylesheet to target the 'first' end column.
     * (e.g. We only want to apply drop shadow on first pinned end column - not all pinned columns)
     */
    handlePinnedEndChange() {
        writeTask(() => {
            if (this.pinnedEnd.length) {
                this.tableEle.classList.add(`${CSSNAMESPACE}__pinned--end`);
                const firstActiveCol = this.cachedColMeta.get(this.pinnedEnd[0]);
                this.addStyleSheet(`${this.tableId}-col-end-active-style`, 
                /* css */ `
          #${this.tableId} tr > :nth-child(${firstActiveCol.idx + 1}) { --pin-end-active: 1; }
        `);
            }
            else {
                this.tableEle.classList.remove(`${CSSNAMESPACE}__pinned--end`);
                this.addStyleSheet(`${this.tableId}-col-end-active-style`, ``);
            }
        });
    }
    get cssColDimensionCacheKey() {
        return this._cssColDimensionCacheKey;
    }
    set cssColDimensionCacheKey(key) {
        if (key === this._cssColDimensionCacheKey)
            return;
        this._cssColDimensionCacheKey = key;
        this.createPinnedColDimensionStyles();
    }
    _cssColDimensionCacheKey = '';
    /**
     * To only generate column dimension styles when necessary we
     * maintain a cache key string via serialised column meta.
     * Only when this key changes do we generate a new stylesheet
     */
    generateCssCacheKey() {
        let key = '';
        for (const col of this.startColumns) {
            const colMeta = this.cachedColMeta.get(col);
            key += `${colMeta.idx}-start-${colMeta.width}`;
        }
        for (const col of this.endColumns) {
            const colMeta = this.cachedColMeta.get(col);
            key += `${colMeta.idx}-start-${colMeta.width}`;
        }
        this.cssColDimensionCacheKey = key;
    }
    /**
     * Generates pinned column width offset styles
     * so pinned columns can appear stuck together,
     * then attaches a stylesheet.
     */
    createPinnedColDimensionStyles() {
        let widthS = 0;
        let widthE = 0;
        const startCols = Array.from(this.startColumns);
        const endCols = Array.from(this.endColumns).reverse();
        const css = /* css */ `
      ${startCols
            .map((col) => {
            const colMeta = this.cachedColMeta.get(col);
            widthS += colMeta.width - 1 || -1;
            return /* css */ `
            #${this.tableId} tr > :nth-child(${colMeta.idx + 1}) ~ td,
            #${this.tableId} tr > :nth-child(${colMeta.idx + 1}) ~ th {
              --pin-start: ${widthS - 1}px;
            }
          `;
        })
            .join('')}
      ${endCols
            .map((col) => {
            const colMeta = this.cachedColMeta.get(col);
            widthE += colMeta.width - 1 || -1;
            return /* css */ `
            #${this.tableId} tr > td:has(~ :nth-child(${colMeta.idx + 1})),
            #${this.tableId} tr > th:has(~ :nth-child(${colMeta.idx + 1})) {
              --pin-end: ${widthE - 1}px;
            }
          `;
        })
            .join('')}
    `;
        this.addStyleSheet(`${this.tableId}-dimension-style`, css);
    }
    getParentOffsets() {
        const { x, y } = this.scrollElement.getBoundingClientRect();
        let offsetX = x;
        let offsetY = y;
        if (this.scrollElement === document.documentElement) {
            offsetX = this.scrollElement.offsetLeft;
            offsetY = this.scrollElement.offsetTop;
        }
        return { offsetX, offsetY };
    }
    /**
     * Loops through all 'top' & 'bottom' rows (on scroll or resize)
     * Manages their visual state by applying classes on stuck / unstuck
     * And their pinned offset / distance
     */
    assessRows() {
        if (!this.topRows.length && !this.bottomRows.length)
            return;
        // top rows
        if (this.topRows.length) {
            readTask(async () => {
                let heightAggregate = 0;
                let cacheParent;
                const { offsetY } = this.getParentOffsets();
                for (const topRow of this.topRows) {
                    const { y, height } = topRow.getBoundingClientRect();
                    const currParent = topRow.parentElement;
                    // we'll use the applied `--pin-top` css var to decide row offset.
                    // This allows devs to override this behaviour through selector specificity
                    const pinTop = getComputedStyle(topRow).getPropertyValue('--pin-top');
                    const offset = pinTop !== '' ? parseFloat(pinTop) : heightAggregate;
                    // we need to wait for the row to finish sticking
                    // and generating it's offset (`--pin-top`) so we can see, on
                    // subsequent rows *if* the offset was applied
                    writeTask(() => {
                        if (y - offsetY <= offset) {
                            topRow.classList.add(`${CSSNAMESPACE}__pinned`, `${CSSNAMESPACE}__pinned--top`);
                        }
                        else {
                            topRow.classList.remove(`${CSSNAMESPACE}__pinned`, `${CSSNAMESPACE}__pinned--top`);
                        }
                        // by default, we only want to stick one row from each parental block (thead, tbody, tfoot)
                        // so only aggregate height / offset when cacheParent is different from current parent.
                        // Devs can override this behaviour by manually setting `--pin-bottom` on the table row
                        if (cacheParent !== currParent) {
                            currParent.style.setProperty('--pin-top', `${heightAggregate - 1}px`);
                            heightAggregate += height - 1;
                            cacheParent = currParent;
                        }
                    });
                }
            });
        }
        // bottom rows
        if (this.bottomRows.length) {
            const bottomRows = Array.from(this.bottomRows).reverse();
            readTask(async () => {
                let cacheParent;
                let heightAggregate = 0;
                const { offsetY } = this.getParentOffsets();
                for (const bottomRow of bottomRows) {
                    if (!bottomRow.isConnected)
                        continue;
                    const { y, height } = bottomRow.getBoundingClientRect();
                    const currParent = bottomRow.parentElement;
                    // we'll use the applied `--pin-bottom` css var to decide row offset.
                    // This allows devs to override this behaviour through selector specificity
                    const pinBottom = getComputedStyle(bottomRow).getPropertyValue('--pin-bottom');
                    const offset = pinBottom !== '' ? parseFloat(pinBottom) : heightAggregate;
                    // we need to wait for the row to finish sticking
                    // and generating it's offset (`--pin-bottom`) to we can see, on
                    // subsequent rows *if* the offset was applied
                    await new Promise((resolve) => writeTask(() => {
                        if (this.tableDims.height + offsetY - (y + height) <= offset) {
                            bottomRow.classList.add(`${CSSNAMESPACE}__pinned`, `${CSSNAMESPACE}__pinned--bottom`);
                        }
                        else {
                            bottomRow.classList.remove(`${CSSNAMESPACE}__pinned`, `${CSSNAMESPACE}__pinned--bottom`);
                        }
                        // by default, we only want to stick one row from each parental block (thead, tbody, tfoot)
                        // so only aggregate height / offset when cacheParent is different from current parent.
                        // Devs can override this behaviour by manually setting `--pin-bottom` on the table row
                        if (cacheParent !== currParent) {
                            currParent.style.setProperty('--pin-bottom', `${heightAggregate - 1}px`);
                            heightAggregate += height - 1;
                            cacheParent = currParent;
                        }
                        resolve();
                    }));
                }
            });
        }
    }
    /**
     * Loops through all 'start' & 'end' columns (on scroll or resize)
     * Caches meta about each column (e.g. size, position-index)
     * and decides which columns are pinned
     */
    async assessCols() {
        if (!this.startColumns.length && !this.endColumns.length)
            return;
        let boundBox;
        let currPinned;
        let parentEles;
        const done = new Promise((resolve) => {
            // start cols
            if (this.startColumns.length) {
                readTask(() => {
                    const { offsetX } = this.getParentOffsets();
                    // cumulatively add widths of columns together
                    // 'cos columns stick together
                    let widthAggregate = offsetX;
                    parentEles = Array.from(this.startColumns[0].parentElement.children);
                    for (const startCol of this.startColumns) {
                        boundBox = startCol.getBoundingClientRect();
                        // cache meta for later
                        this.cachedColMeta.set(startCol, {
                            width: boundBox.width,
                            idx: parentEles.indexOf(startCol),
                        });
                        currPinned = this.pinnedStart.find((c) => c === startCol);
                        if (boundBox.x < widthAggregate) {
                            // this column is pinned
                            if (!currPinned)
                                this.pinnedStart = [
                                    ...this.pinnedStart,
                                    startCol,
                                ];
                        }
                        else if (currPinned) {
                            // this column is unpinned
                            this.pinnedStart = this.pinnedStart.filter((c) => c !== startCol);
                        }
                        widthAggregate += boundBox.width;
                    }
                    if (!this.endColumns.length)
                        resolve();
                });
            }
            // end cols
            if (this.endColumns.length) {
                readTask(() => {
                    const endCols = Array.from(this.endColumns).reverse();
                    parentEles = Array.from(this.endColumns[0].parentElement.children);
                    const { offsetX } = this.getParentOffsets();
                    // cumulatively add widths of columns together
                    // 'cos columns stick together
                    let widthAggregate = 0;
                    for (const endCol of endCols) {
                        boundBox = endCol.getBoundingClientRect();
                        // cache meta for later
                        this.cachedColMeta.set(endCol, {
                            width: boundBox.width,
                            idx: parentEles.indexOf(endCol),
                        });
                        currPinned = this.pinnedEnd.find((c) => c === endCol);
                        if (this.tableDims.width + offsetX - boundBox.right <=
                            widthAggregate) {
                            // this column is pinned
                            if (!currPinned)
                                this.pinnedEnd = [endCol, ...this.pinnedEnd];
                        }
                        else if (currPinned) {
                            // this column is unpinned
                            this.pinnedEnd = this.pinnedEnd.filter((c) => c !== endCol);
                        }
                        widthAggregate += boundBox.width;
                    }
                    resolve();
                });
            }
        });
        await done;
        // potentially generate a new css stylesheet if anything changed
        this.generateCssCacheKey();
    }
    cacheX = 0;
    cacheY = 0;
    onScroll(pos) {
        if (this.cacheX !== pos.x) {
            this.cacheX = pos.x;
            this.assessCols();
        }
        if (this.cacheY !== pos.y) {
            this.cacheY = pos.y;
            this.assessRows();
        }
    }
    onResize() {
        const width = this.scrollElement.clientWidth;
        const height = this.scrollElement.clientHeight;
        this.tableDims = { width, height };
        this.assessCols();
        this.assessRows();
    }
}
