import { LitElement, html, nothing } from 'lit';
import {
    customElement,
    property,
    state,
    query,
} from 'lit/decorators.js';
import '@lit-labs/virtualizer';
import type {
    LitVirtualizer,
    VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import {
    GetAlbumTracks,
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { AlbumSelectionManager } from './album-selection.js';
import { ScrollManager } from './scroll-manager.js';
import type { ScrollManagerHost } from './scroll-manager.js';
import './album-dropdown.js';
import type {
    TrackClickDetail,
    TrackDblClickDetail,
    TrackContextMenuDetail,
    TrackDragStartDetail,
} from './album-dropdown.js';
import {
    DRAG_MIME,
    setDragPayload,
    emitDragActive,
} from '@utils/drag-controller';
import type { DragPayload } from '@utils/drag-controller';
import {
    ContextMenuController,
    isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
import { creditStore } from '@store/credit-store';
import {
    createAlbumArtDragImage,
    createDragImage,
    createTrackCardDragImage,
    removeDragImage,
} from '@utils/drag-image';
import { coverGridStyles } from './cover-grid-styles.js';
import '@components/page-header/page-header';
import {
    ALBUM_SORT_OPTIONS,
    SORT_DIR_KEY,
    SORT_FIELD_KEY,
    ZOOM_STEP,
} from './cover-grid-types.js';
import type {
    AlbumSortField,
    ContextMenuTarget,
    GridEntry,
    SortDirection,
} from './cover-grid-types.js';
import { list } from '@utils/binding';
import {
    ICON_PLAYLIST,
    ICON_QUEUE,
} from '@utils/icon-language';

@customElement('cover-grid')
export class CoverGrid
    extends ViewLifecycleMixin(LitElement)
    implements ContextMenuHost, ScrollManagerHost
{
    /**
     * When set, the grid displays these albums instead of
     * fetching all albums from the library store.  The
     * parent is responsible for reloading when data changes.
     */
    @property({ type: Array, attribute: false })
    externalAlbums?: library.Album[];

    libraryCtrl = new LibraryController(this);
    private searchCtrl = new SearchController(this);
    private lastSearchTerm = '';

    /** Tracks the store's cached array reference to detect refreshes. */
    private lastAlbumsRef: library.Album[] | null =
        null;

    private static readonly CARD_PADDING = 5;

    private ctxMenu = new ContextMenuController(this);
    private favCtrl = new FavoritesController(this);
    private selMgr = new AlbumSelectionManager();
    private scrollMgr = new ScrollManager(this, {
        columnsFor: (width: number) => this.columnsFor(width),
        spacingFor: (width: number) => this.spacingFor(width),
    });

    /**
     * How many cards fit across `width`, by the same arithmetic the
     * virtualizer's `space-evenly` grid uses — no gap and no padding
     * are reserved, because both come out of what is left over.
     *
     * The scroll manager restores a position by rebuilding the grid's
     * geometry, so this and `spacingFor` must agree with the layout
     * rather than approximate it; they were two constants that no
     * longer describe anything once the spacing became elastic.
     */
    columnsFor(width: number): number {
        return gridColumnsFor(width, this.cardWidth);
    }

    /** The spacing that width produces: between columns, between rows,
     *  and around the outside, all the same number. */
    spacingFor(width: number): number {
        return gridSpacingFor(width, this.cardWidth);
    }

    private lastSelectedAlbumIndex: number | null = null;
    private lastSelectedTrackIndex: number | null = null;

    /**
     * When true, the next split→single transition
     * skips the expensive overlay capture and scroll
     * restore.
     */
    private skipOverlay = false;

    /** Current card width — driven by the store. */
    get cardWidth(): number {
        return this.libraryCtrl.coverSize;
    }

    /**
     * Height of the text area below the cover image.
     * Two lines: album name (+ year) and artist.
     */
    private get cardTextHeight(): number {
        const w = this.cardWidth;

        if (w < 160) return 36;

        return w > 250 ? 46 : 40;
    }

    /** Derived card height from card width. */
    get cardHeight(): number {
        return this.cardWidth + this.cardTextHeight;
    }

    /** Image size inside the card (card minus padding). */
    private get imageSize(): number {
        return this.cardWidth - CoverGrid.CARD_PADDING * 2;
    }

    // Virtualizer grid layout instance — recreated when
    // the card size or the container width changes.
    private gridLayout = this.createGridLayout();
    private gridLayoutWidth = 0;

    /** The spacing the current layouts were built with. */
    private gridLayoutSpacing = 0;

    /** Watches the scroll container so a window resize rebuilds the
     *  layout: the spacing is derived from its width, and nothing else
     *  asks this component to update when only that changes. */
    private gridResizeObserver: ResizeObserver | null =
        null;

    private observeGridWidth(): void {
        const container = this.scrollContainer;

        if (!container || this.gridResizeObserver) return;

        this.gridResizeObserver = new ResizeObserver(
            () => this.requestUpdate(),
        );
        this.gridResizeObserver.observe(container);
    }

    /**
     * Secondary layout for the "after" virtualizer in
     * split mode.  Uses zero top padding so there is no
     * extra gap between the dropdown and the first row.
     */
    private gridLayoutAfter = this.createGridLayout(
        true,
    );

    private createGridLayout(noTopPad = false) {
        const w = this.libraryCtrl?.coverSize ?? 176;

        if (!noTopPad) {
            this.gridLayoutWidth = w;
        }

        const h = w + this.cardTextHeight;

        // The spacing is whatever the row could not spend on another
        // card, shared out equally — so it is the same number between
        // two cards, between two rows, and down each outside edge.
        // See `utils/grid-spacing.ts` for why it is computed rather
        // than handed to the virtualizer as `space-evenly`.
        const spacing = this.spacingFor(
            this.containerWidth,
        );

        if (!noTopPad) {
            this.gridLayoutSpacing = spacing;
        }

        return grid({
            itemSize: {
                width: `${w}px`,
                height: `${h}px`,
            },
            gap: `${spacing}px`,
            padding: noTopPad
                ? `0 ${spacing}px ${spacing}px`
                : `${spacing}px`,
            justify: 'start',
        });
    }

    /**
     * The width the grid lays itself out in.
     *
     * Read from the scroll container when there is one; before the
     * first render there is not, and the fallback only has to be
     * plausible — the layout is rebuilt from the real width as soon as
     * one exists.
     */
    private get containerWidth(): number {
        return (
            this.scrollContainer?.clientWidth ||
            this.clientWidth ||
            0
        );
    }

    private dragImageEl: HTMLElement | null = null;

    // -- Memoisation caches for filtered albums --
    cachedFilteredAlbums: library.Album[] = [];
    private prevFilterAlbums: library.Album[] = [];
    private prevFilterTerm = '';
    private prevSortField: AlbumSortField = 'name';
    private prevSortDir: SortDirection = 'asc';

    // =================================================================
    // Filtered albums (memoised)
    // =================================================================

    /**
     * Recompute the filtered-albums cache when its
     * inputs have changed.  Called from willUpdate()
     * so the cache is ready before render().
     */
    private recomputeAlbumCache() {
        const term = this.searchCtrl.term;

        if (
            this.albums !== this.prevFilterAlbums ||
            term !== this.prevFilterTerm ||
            this.sortField !== this.prevSortField ||
            this.sortDirection !== this.prevSortDir
        ) {
            this.prevFilterAlbums = this.albums;
            this.prevFilterTerm = term;
            this.prevSortField = this.sortField;
            this.prevSortDir = this.sortDirection;
            this.cachedFilteredAlbums =
                this.computeFilteredAlbums();
        }
    }

    private computeFilteredAlbums(): library.Album[] {
        const term =
            this.searchCtrl.term.toLowerCase();

        let albums: library.Album[];

        if (!term) {
            albums = this.albums;
        } else {
            albums = this.albums.filter(
                (a) =>
                    a.Name.toLowerCase().includes(
                        term,
                    ) ||
                    a.ArtistName.toLowerCase().includes(
                        term,
                    ),
            );
        }

        // Apply sort.
        const opt = ALBUM_SORT_OPTIONS.find(
            (o) => o.id === this.sortField,
        );

        if (!opt) return albums;

        const dir =
            this.sortDirection === 'asc' ? 1 : -1;

        return [...albums].sort(
            (a, b) => dir * opt.comparator(a, b),
        );
    }

    /** Wheel event handler ref for manual add/remove. */
    private wheelHandler = (e: WheelEvent) => {
        this.onWheel(e);
    };

    private wheelListenerAttached = false;

    // buildGridEntries() memoization cache.
    private gridEntriesCache: GridEntry[] = [];
    private gridEntriesCacheKey: library.Album[] = [];

    // getBeforeEntries/getAfterEntries memoization — prevents .slice()
    // from creating new array refs that trigger virtualizer relayout.
    private beforeEntriesCache: GridEntry[] = [];
    private afterEntriesCache: GridEntry[] = [];
    private splitEntriesCacheKey: GridEntry[] | null = null;
    private splitEntriesCacheIndex = -1;

    static override styles = [coverGridStyles, exploreLinkStyles];

    /* ====================================================================
     * Reactive state
     * ==================================================================== */

    @state()
    private albums: library.Album[] = [];

    @state()
    private loading = true;

    /** One tab stop for the grid, moved with the arrow keys — a card per
     *  tab stop makes a library-length tab sequence (H-5). */
    private roving = new RovingGridController(this, {
        cardSelector: '.album-card',
        count: () => this.buildGridEntries().length,
        scrollToIndex: (index) => this.scrollGridToIndex(index),
    });

    private contextMenuTarget: ContextMenuTarget = {
        kind: 'album',
    };

    /**
     * Album ID that was right-clicked to open the
     * context menu.  Used as fallback when the
     * right-clicked album is not part of the current
     * visual selection.
     */
    private contextMenuAlbumId: number | null = null;

    @state()
    private selectedAlbums: Set<number> = new Set();

    /** Current album sort field. */
    @state()
    private sortField: AlbumSortField = 'name';

    /** Current sort direction. */
    @state()
    private sortDirection: SortDirection = 'asc';

    /** ID of the album whose dropdown is currently open, or null. */
    @state()
    expandedAlbumId: number | null = null;

    /** Tracks loaded for the expanded album dropdown. */
    @state()
    expandedTracks: library.Track[] = [];

    /** Set of file paths of selected tracks inside the dropdown. */
    @state()
    private selectedTracks: Set<string> = new Set();

    /**
     * True when using the dual-virtualizer layout
     * (dropdown sandwiched between two grids).
     */
    @state()
    splitMode = false;

    /**
     * Index into this.albums where the split occurs.
     * Albums [0, splitIndex) go into the "before"
     * virtualizer; [splitIndex, length) go into "after".
     * Not `@state()` — always set before `splitMode`
     * changes, which triggers the render.
     */
    splitIndex = 0;

    @query('#context-menu')
    private contextMenuPopup!: WaPopup;

    @query('#playlist-submenu')
    private playlistSubmenuPopup!: WaPopup;

    // ContextMenuHost interface.
    getContextMenuPopup(): WaPopup | undefined {
        return this.contextMenuPopup;
    }

    getPlaylistSubmenuPopup(): WaPopup | undefined {
        return this.playlistSubmenuPopup;
    }

    onContextMenuClose(): void {
        this.contextMenuAlbumId = null;
    }

    @query('track-details')
    private trackDetailsDialog!: TrackDetails;

    @query('#grid-single')
    private virtualizerSingle!: LitVirtualizer;

    @query('.grid-scroll-container')
    private scrollContainer!: HTMLElement;



    /* ====================================================================
     * Sort controls
     * ==================================================================== */

    /** Restore sort preferences from localStorage. */
    private restoreSortPreferences() {
        try {
            const field = localStorage.getItem(
                SORT_FIELD_KEY,
            );
            const dir =
                localStorage.getItem(SORT_DIR_KEY);

            if (
                field &&
                ALBUM_SORT_OPTIONS.some(
                    (o) => o.id === field,
                )
            ) {
                this.sortField =
                    field as AlbumSortField;
            }

            if (dir === 'asc' || dir === 'desc') {
                this.sortDirection = dir;
            }
        } catch {
            // Ignore storage errors.
        }
    }

    /** Persist sort preferences to localStorage. */
    private saveSortPreferences() {
        try {
            localStorage.setItem(
                SORT_FIELD_KEY,
                this.sortField,
            );
            localStorage.setItem(
                SORT_DIR_KEY,
                this.sortDirection,
            );
        } catch {
            // Ignore storage errors.
        }
    }

    /* ====================================================================
     * Lifecycle
     * ==================================================================== */

    /** Unsubscribes the credit-arrival repaint. */
    private creditsUnsub?: () => void;

    override connectedCallback() {
        super.connectedCallback();

        this.creditsUnsub = creditStore.subscribe(() => {
            this.requestUpdate();
            // Two virtualizers when the grid is split; both draw rows.
            this.renderRoot?.querySelectorAll('lit-virtualizer')
                .forEach((v) => (v as unknown as { requestUpdate(): void }).requestUpdate());
        });
        this.restoreSortPreferences();
        this.loadAlbums();

        // error events do not bubble — use capture
        // phase to catch <img> load failures.
        this.addEventListener(
            'error',
            this.onGridImageError,
            true,
        );
    }

    override disconnectedCallback() {
        super.disconnectedCallback();
        this.creditsUnsub?.();
        this.creditsUnsub = undefined;

        this.removeEventListener(
            'error',
            this.onGridImageError,
            true,
        );
        this.scrollContainer?.removeEventListener(
            'wheel',
            this.wheelHandler,
        );
        this.wheelListenerAttached = false;

        this.gridResizeObserver?.disconnect();
        this.gridResizeObserver = null;

        this.scrollMgr.teardown();
        this.scrollMgr.revealContainer(
            this.scrollContainer,
        );
    }

    override willUpdate(
        changed: Map<PropertyKey, unknown>,
    ) {
        super.willUpdate(changed);
        this.recomputeAlbumCache();

        // When the parent provides a new external album
        // list, update local albums and reset selection.
        if (changed.has('externalAlbums') && this.externalAlbums) {
            this.albums = this.externalAlbums;
            this.selMgr.setAlbums(this.externalAlbums);
            this.selectedAlbums = new Set();
            this.lastSelectedAlbumIndex = null;
            this.loading = false;
        }

        // When switching albums, expandedTracks
        // briefly goes to [].  Exit split mode so
        // the single virtualizer takes over while
        // loading.
        if (
            changed.has('expandedTracks') &&
            this.expandedTracks.length === 0 &&
            this.splitMode
        ) {
            const sm = this.scrollMgr;

            if (this.skipOverlay) {
                this.skipOverlay = false;
                sm.savedScrollTop =
                    sm.computeAdjustedScrollTop(
                        this.scrollContainer,
                        this.shadowRoot,
                    );
                sm.savedAlbumViewportOffset = null;
                this.splitMode = false;
                sm.needsScrollRestore = true;
                sm.showDropdownAfterRestore = false;
            } else {
                sm.savedScrollTop =
                    sm.computeAdjustedScrollTop(
                        this.scrollContainer,
                        this.shadowRoot,
                    );

                sm.captureAnchorOffset(
                    this.scrollContainer,
                    this.shadowRoot,
                );

                sm.captureOverlay(
                    this.scrollContainer,
                    this.shadowRoot,
                );
                this.splitMode = false;
                sm.needsScrollRestore = true;
                sm.showDropdownAfterRestore = false;
            }
        }

        // Enter split mode when tracks have loaded.
        if (
            changed.has('expandedTracks') &&
            this.expandedAlbumId !== null &&
            this.expandedTracks.length > 0
        ) {
            const sm = this.scrollMgr;

            if (!sm.restoreInFlight) {
                sm.savedScrollTop =
                    this.scrollContainer
                        ?.scrollTop ?? 0;
            }

            sm.captureOverlay(
                this.scrollContainer,
                this.shadowRoot,
            );
            this.splitIndex =
                sm.computeSplitIndex(
                    this.scrollContainer,
                );
            this.splitMode = true;
            sm.needsScrollRestore = true;
            sm.showDropdownAfterRestore = true;
        }

        // Exit split mode when the dropdown closes.
        if (
            changed.has('expandedAlbumId') &&
            this.expandedAlbumId === null &&
            this.splitMode
        ) {
            const sm = this.scrollMgr;

            sm.savedScrollTop =
                sm.computeAdjustedScrollTop(
                    this.scrollContainer,
                    this.shadowRoot,
                );
            sm.savedAlbumViewportOffset = null;

            sm.captureOverlay(
                this.scrollContainer,
                this.shadowRoot,
            );
            this.splitMode = false;
            sm.needsScrollRestore = true;
            sm.showDropdownAfterRestore = false;
        }
    }

    override updated(
        changed: Map<PropertyKey, unknown>,
    ) {
        super.updated(changed);

        // Ctrl+Scroll zoom — lazily attach to the
        // scroll container once it exists in the DOM.
        if (
            !this.wheelListenerAttached &&
            this.scrollContainer
        ) {
            this.scrollContainer.addEventListener(
                'wheel',
                this.wheelHandler,
                { passive: false },
            );
            this.wheelListenerAttached = true;
        }

        this.observeGridWidth();

        // Recreate the virtualizer grid layout when the card size
        // changes — or when the spacing the container width produces
        // does, since that is now a derived number rather than a
        // constant.  Keyed on the spacing rather than on the width, or
        // every pixel of a drag rebuilds a layout that would come out
        // the same.
        const cardSizeChanged =
            this.gridLayoutWidth !== this.cardWidth ||
            this.gridLayoutSpacing !==
                this.spacingFor(this.containerWidth);

        if (cardSizeChanged) {
            this.gridLayout = this.createGridLayout();
            this.gridLayoutAfter =
                this.createGridLayout(true);
        }

        // Apply CSS custom properties for dynamic sizing.
        this.updateSizeProperties();

        // Restore scroll after a single/split mode
        // transition (set in willUpdate).
        if (this.scrollMgr.needsScrollRestore) {
            this.scrollMgr.runScrollRestore(
                this.scrollContainer,
                this.shadowRoot,
                this.expandedAlbumId,
                this.updateComplete,
            );
        }

        // Close dropdown and clear selection when
        // the search term changes.
        const currentTerm = this.searchCtrl.term;

        if (currentTerm !== this.lastSearchTerm) {
            this.lastSearchTerm = currentTerm;
            this.closeDropdown();
            this.selectedAlbums = new Set();
            this.lastSelectedAlbumIndex = null;
        }

        // On zoom while dropdown is open, recompute
        // the split (column count may change) and
        // re-scroll after layout settles.
        if (
            cardSizeChanged &&
            this.splitMode &&
            this.expandedTracks.length > 0
        ) {
            this.splitIndex =
                this.scrollMgr.computeSplitIndex(
                    this.scrollContainer,
                );

            const sm = this.scrollMgr;

            void (async () => {
                await this.updateComplete;

                await sm.awaitBeforeLayout(
                    this.shadowRoot,
                );

                await sm.scrollToShowDropdown(
                    this.scrollContainer,
                    this.shadowRoot,
                );
            })();
        }

        // Re-fetch when the store delivers fresh
        // data after eager refetch on invalidation.
        if (!this.externalAlbums) {
            const cached =
                this.libraryCtrl.cachedAlbums;

            if (
                cached !== null &&
                cached !== this.lastAlbumsRef
            ) {
                this.lastAlbumsRef = cached;
                this.loadAlbums();
            }
        }
    }

    /* ====================================================================
     * Dynamic size properties
     * ==================================================================== */

    private updateSizeProperties() {
        const w = this.cardWidth;

        this.style.setProperty(
            '--card-width',
            `${w}px`,
        );

        // Scale placeholder initial font.
        const placeholderFont =
            Math.max(16, Math.round(w * 0.3));
        this.style.setProperty(
            '--placeholder-font',
            `${placeholderFont}px`,
        );

        // Text sizing tiers mapped to design tokens.
        if (w < 160) {
            this.classList.add('size-small');
            this.style.setProperty(
                '--album-name-font',
                'var(--yj-text-xs)',
            );
            this.style.setProperty(
                '--artist-name-font',
                '10px',
            );
        } else if (w > 250) {
            this.classList.remove('size-small');
            this.style.setProperty(
                '--album-name-font',
                'var(--yj-text-lg)',
            );
            this.style.setProperty(
                '--artist-name-font',
                'var(--yj-text-md)',
            );
        } else {
            this.classList.remove('size-small');
            this.style.setProperty(
                '--album-name-font',
                'var(--yj-text-lg)',
            );
            this.style.setProperty(
                '--artist-name-font',
                'var(--yj-text-sm)',
            );
        }
    }

    /* ====================================================================
     * Ctrl+Scroll zoom
     * ==================================================================== */

    private onWheel(e: WheelEvent) {
        if (!e.ctrlKey) return;

        e.preventDefault();

        const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;

        this.libraryCtrl.coverSize =
            this.cardWidth + delta;
    }

    /* ====================================================================
     * Data loading
     * ==================================================================== */

    private async loadAlbums() {
        try {
            this.loading = true;

            // When driven by an external album list,
            // skip the backend fetch entirely.
            const albums = this.externalAlbums
                ?? (await this.libraryCtrl.getAlbums())
                ?? [];

            this.albums = albums;
            this.selMgr.setAlbums(albums);
            this.selectedAlbums = new Set();
            this.lastSelectedAlbumIndex = null;
        } catch (error) {
            console.error(
                'Error loading albums:',
                error,
            );
            this.albums = [];
            this.selMgr.setAlbums([]);
        } finally {
            this.loading = false;
        }

        await this.updateComplete;

        this.scrollMgr.restoreScrollPosition(
            this.virtualizerSingle,
        );
        this.scrollMgr.setupResizeObserver(
            this.scrollContainer,
            async () => {
                this.splitIndex =
                    this.scrollMgr.computeSplitIndex(
                        this.scrollContainer,
                    );
                this.requestUpdate();

                await this.updateComplete;

                await this.scrollMgr.awaitBeforeLayout(
                    this.shadowRoot,
                );

                await this.scrollMgr.scrollToShowDropdown(
                    this.scrollContainer,
                    this.shadowRoot,
                );
            },
        );
    }

    /* ====================================================================
     * Scroll event handler
     * ==================================================================== */

    private onVisibilityChanged = (
        e: VisibilityChangedEvent,
    ) => {
        const sm = this.scrollMgr;
        const isSplit = this.splitMode;

        sm.onVisibilityChanged(e.first, () =>
            isSplit
                ? this.getBeforeEntries()
                : this.buildGridEntries(),
        );
    };

    /* ====================================================================
     * Virtualizer items
     * ==================================================================== */

    /**
     * Build a flat GridEntry array for the filtered
     * albums.  Memoized on the cachedFilteredAlbums
     * reference — only allocates a new array when the
     * underlying album list changes.
     */
    private buildGridEntries(): GridEntry[] {
        const filtered = this.cachedFilteredAlbums;

        if (filtered === this.gridEntriesCacheKey) {
            return this.gridEntriesCache;
        }

        const entries: GridEntry[] = [];

        for (let i = 0; i < filtered.length; i++) {
            entries.push({
                album: filtered[i]!,
                albumIndex: i,
            });
        }

        this.gridEntriesCacheKey = filtered;
        this.gridEntriesCache = entries;

        return entries;
    }

    /**
     * Bring a card into view, in whichever virtualizer currently holds
     * it.
     *
     * The roving tab stop's index is an index into the whole filtered
     * album list, but a split grid draws that list across *two*
     * virtualizers, each of which indexes only its own slice. This used
     * to be `querySelector('lit-virtualizer')` — always `#grid-before`
     * — so with a dropdown open, End scrolled the before-grid to an
     * index it does not contain and the card that should have taken
     * focus was never rendered to take it.
     */
    private scrollGridToIndex(index: number): void {
        const split = this.splitMode && this.expandedTracks.length > 0;
        const after = split && index >= this.splitIndex;

        const id = !split
            ? '#grid-single'
            : after
              ? '#grid-after'
              : '#grid-before';

        this.shadowRoot
            ?.querySelector<LitVirtualizer>(id)
            ?.scrollToIndex(after ? index - this.splitIndex : index, 'nearest');
    }

    /** Rebuild before/after caches if the entries or splitIndex changed. */
    private ensureSplitCache(): void {
        const entries = this.buildGridEntries();
        if (
            entries === this.splitEntriesCacheKey &&
            this.splitIndex === this.splitEntriesCacheIndex
        ) {
            return;
        }
        this.splitEntriesCacheKey = entries;
        this.splitEntriesCacheIndex = this.splitIndex;
        this.beforeEntriesCache = entries.slice(0, this.splitIndex);
        this.afterEntriesCache = entries.slice(this.splitIndex);
    }

    /** Entries for the "before" virtualizer (memoized). */
    private getBeforeEntries(): GridEntry[] {
        this.ensureSplitCache();
        return this.beforeEntriesCache;
    }

    /** Entries for the "after" virtualizer (memoized). */
    private getAfterEntries(): GridEntry[] {
        this.ensureSplitCache();
        return this.afterEntriesCache;
    }

    /* ====================================================================
     * Dropdown (expand/collapse)
     * ==================================================================== */

    /** Close the dropdown if one is open. */
    private closeDropdown() {
        if (this.expandedAlbumId === null) return;

        this.expandedAlbumId = null;
        this.expandedTracks = [];
        this.selectedTracks = new Set();
        this.lastSelectedTrackIndex = null;
    }

    /**
     * Open (or switch to) the given album's
     * dropdown. If the dropdown is already open
     * for the same album this is a no-op.
     */
    private async openDropdown(
        album: library.Album,
    ) {
        if (this.expandedAlbumId === album.ID) return;

        this.expandedAlbumId = album.ID;
        this.expandedTracks = [];
        this.selectedTracks = new Set();
        this.lastSelectedTrackIndex = null;

        try {
            const libId =
                this.libraryCtrl.selectedLibraryId;

            const tracks = await list(
                GetAlbumTracks(album.ID, libId ?? 0),
            );

            if (this.expandedAlbumId === album.ID) {
                this.expandedTracks = tracks;
            }
        } catch (error) {
            console.error(
                'Error loading album tracks:',
                error,
            );
        }
    }

    /* ====================================================================
     * Event delegation helpers
     * ==================================================================== */

    /**
     * Walk up from the event target to find the nearest
     * `.album-card` and read its `data-index` attribute.
     * Returns `null` if the click was not on a card.
     */
    private resolveAlbumFromEvent(
        e: Event,
    ): { album: library.Album; index: number } | null {
        const path = e.composedPath();
        const filtered = this.cachedFilteredAlbums;

        for (const el of path) {
            if (
                el instanceof HTMLElement &&
                el.classList.contains('album-card')
            ) {
                const raw = el.dataset['index'];

                if (raw === undefined) return null;

                const index = parseInt(raw, 10);
                const album = filtered[index];

                if (!album) return null;

                return { album, index };
            }
        }

        return null;
    }

    /** The queue source recorded when a full album starts playing. */
    private albumSource(album: library.Album): QueueSource {
        return { type: 'album', id: album.ID, label: album.Name };
    }

    /* ====================================================================
     * Delegated album event handlers
     * ==================================================================== */

    private onGridAlbumClick = (e: MouseEvent) => {
        const hit = this.resolveAlbumFromEvent(e);

        if (!hit) return;

        const { album, index } = hit;
        const isCtrl = e.ctrlKey || e.metaKey;
        const isShift = e.shiftKey;

        if (
            isShift &&
            this.lastSelectedAlbumIndex !== null
        ) {
            const range = this.selMgr.selectAlbumRange(
                this.lastSelectedAlbumIndex,
                index,
                this.cachedFilteredAlbums,
            );
            const next = new Set(this.selectedAlbums);

            for (const id of range) {
                next.add(id);
            }

            this.selectedAlbums = next;
            void this.selMgr.warmCache(
                this.selectedAlbums,
            );
        } else if (isCtrl) {
            const next = new Set(this.selectedAlbums);

            if (next.has(album.ID)) {
                next.delete(album.ID);
            } else {
                next.add(album.ID);
            }

            this.selectedAlbums = next;
            this.lastSelectedAlbumIndex = index;
            void this.selMgr.warmCache(
                this.selectedAlbums,
            );
        } else {
            // Plain click: navigate to explore album page.
            this.selectedAlbums = new Set();
            this.dispatchEvent(
                new CustomEvent('navigate', {
                    bubbles: true,
                    composed: true,
                    detail: {
                        view: 'explore-album-details',
                        releaseGroupMBID: album.MBID || '',
                        albumName: album.Name,
                        localAlbumId: album.ID,
                    },
                }),
            );
        }
    };

    private onGridAlbumDblClick = async (
        e: MouseEvent,
    ) => {
        const hit = this.resolveAlbumFromEvent(e);

        if (!hit) return;

        const filePaths =
            await this.selMgr.getAlbumFilePaths(
                hit.album,
            );

        if (filePaths.length === 0) return;

        this.selectedAlbums = new Set();
        this.closeDropdown();
        queueStore.setQueue(filePaths, 0, true, this.albumSource(hit.album));
    };

    private onGridAlbumKeydown = (
        e: KeyboardEvent,
    ) => {
        if (isContextMenuKey(e)) {
            const target = this.resolveAlbumFromEvent(e);
            const card =
                e.composedPath().find(
                    (el): el is HTMLElement =>
                        el instanceof HTMLElement &&
                        el.classList.contains('album-card'),
                );

            if (!target || !card) return;

            e.preventDefault();
            e.stopPropagation();
            this.contextMenuAlbumId = target.album.ID;
            this.contextMenuTarget = { kind: 'album' };
            this.ctxMenu.openFrom(card);

            return;
        }

        if (e.key !== 'Enter' && e.key !== ' ') return;

        const hit = this.resolveAlbumFromEvent(e);

        if (!hit) return;

        e.preventDefault();

        const { album, index } = hit;

        // Mirror plain-click behaviour: toggle
        // sole selection, or select and open.
        if (
            this.selectedAlbums.size === 1 &&
            this.selectedAlbums.has(album.ID)
        ) {
            this.selectedAlbums = new Set();
            this.closeDropdown();
        } else {
            this.selectedAlbums = new Set([album.ID]);
            void this.openDropdown(album);
        }

        this.lastSelectedAlbumIndex = index;
        void this.selMgr.warmCache(
            this.selectedAlbums,
        );
    };

    private onGridAlbumContextMenu = (
        e: MouseEvent,
    ) => {
        const hit = this.resolveAlbumFromEvent(e);

        if (!hit) return;

        e.preventDefault();
        e.stopPropagation();

        this.contextMenuAlbumId = hit.album.ID;
        this.contextMenuTarget = { kind: 'album' };
        this.ctxMenu.openAt(e.clientX, e.clientY);
    };

    /**
     * Delegated image error handler — falls back from
     * thumbnail to full-size cover art.
     */
    private onGridImageError = (e: Event) => {
        const img = e.target;

        if (!(img instanceof HTMLImageElement)) return;

        if (!img.classList.contains('cover-image')) {
            return;
        }

        const card = img.closest('.album-card');

        if (!card) return;

        const raw = (card as HTMLElement).dataset[
            'index'
        ];

        if (raw === undefined) return;

        const index = parseInt(raw, 10);
        const album = this.cachedFilteredAlbums[index];

        if (album && img.src !== album.CoverArtPath) {
            img.src = album.CoverArtPath;
        }
    };

    /* ====================================================================
     * Track event handlers (from album-dropdown)
     * ==================================================================== */

    private onTrackClick = (
        e: CustomEvent<TrackClickDetail>,
    ) => {
        const {
            track,
            index,
            ctrlKey,
            shiftKey,
            metaKey,
        } = e.detail;

        const isCtrl = ctrlKey || metaKey;

        if (
            shiftKey &&
            this.lastSelectedTrackIndex !== null
        ) {
            const range = this.selMgr.selectTrackRange(
                this.lastSelectedTrackIndex,
                index,
                this.expandedTracks,
            );
            const next = new Set(this.selectedTracks);

            for (const path of range) {
                next.add(path);
            }

            this.selectedTracks = next;
        } else if (isCtrl) {
            const next = new Set(this.selectedTracks);

            if (next.has(track.FilePath)) {
                next.delete(track.FilePath);
            } else {
                next.add(track.FilePath);
            }

            this.selectedTracks = next;
            this.lastSelectedTrackIndex = index;
        } else {
            this.selectedTracks = new Set([
                track.FilePath,
            ]);
            this.lastSelectedTrackIndex = index;
        }
    };

    private onTrackDblClick = (
        e: CustomEvent<TrackDblClickDetail>,
    ) => {
        const { index } = e.detail;

        // Play the full album starting from this track
        const filePaths = this.expandedTracks.map(
            (t) => t.FilePath,
        );

        if (filePaths.length === 0) return;

        this.selectedTracks = new Set();

        const album = this.albums.find(
            (a) => a.ID === this.expandedAlbumId,
        );

        queueStore.setQueue(
            filePaths,
            index,
            false,
            album ? this.albumSource(album) : undefined,
        );
    };

    private onTrackContextMenu = (
        e: CustomEvent<TrackContextMenuDetail>,
    ) => {
        const { track, clientX, clientY } = e.detail;

        if (!this.selectedTracks.has(track.FilePath)) {
            this.selectedTracks = new Set([
                track.FilePath,
            ]);
        }

        this.contextMenuTarget = { kind: 'track' };
        this.ctxMenu.openAt(clientX, clientY);
    };

    /* ====================================================================
     * Drag source (dropdown tracks)
     * ==================================================================== */

    private onTrackDragStart = (
        e: CustomEvent<TrackDragStartDetail>,
    ) => {
        const { track, dataTransfer } = e.detail;

        let filePaths: string[];

        if (this.selectedTracks.has(track.FilePath)) {
            filePaths =
                this.selMgr.getSelectedTrackFilePaths(
                    this.selectedTracks,
                    this.expandedTracks,
                );
        } else {
            filePaths = [track.FilePath];
        }

        if (filePaths.length === 0) return;

        if (dataTransfer) {
            const payload: DragPayload = {
                filePaths,
                source: 'cover-grid',
            };

            dataTransfer.effectAllowed = 'copy';
            dataTransfer.setData(
                DRAG_MIME,
                JSON.stringify(payload),
            );

            this.dragImageEl =
                filePaths.length === 1
                    ? createTrackCardDragImage(
                          track.TrackName,
                          track.ArtistName,
                          track.FilePath,
                      )
                    : createDragImage(
                          filePaths.length,
                      );
            dataTransfer.setDragImage(
                this.dragImageEl,
                0,
                0,
            );
        }

        emitDragActive(true);
    };

    private onTrackDragEnd = () => {
        if (this.dragImageEl) {
            removeDragImage(this.dragImageEl);
            this.dragImageEl = null;
        }

        emitDragActive(false);
    };

    /* ====================================================================
     * Drag source (album cards)
     * ==================================================================== */

    /**
     * Pre-warm the file-path cache for the album under
     * the pointer so that a subsequent dragstart (which
     * is synchronous) can read the paths immediately.
     */
    private onAlbumPointerDown = (e: PointerEvent) => {
        // Only act on primary button (left-click).
        if (e.button !== 0) return;

        const hit = this.resolveAlbumFromEvent(e);

        if (!hit) return;

        // Fire-and-forget: warm the cache entry.
        void this.selMgr.warmSingleAlbum(hit.album);
    };

    private onAlbumDragStart = (e: DragEvent) => {
        const hit = this.resolveAlbumFromEvent(e);

        if (!hit) return;

        // Read file paths synchronously from the
        // pre-warmed cache.  The cache is populated
        // via pointerdown or selection-change warming.
        let filePaths: string[];
        let isSingleAlbum: boolean;

        if (this.selectedAlbums.has(hit.album.ID)) {
            // Dragged album is part of the selection —
            // drag all selected albums' tracks.
            filePaths =
                this.selMgr.getCachedSelectedPaths(
                    this.selectedAlbums,
                );
            isSingleAlbum =
                this.selectedAlbums.size === 1;
        } else {
            // Dragged album is not selected — clear
            // selection and drag only this album.
            this.selectedAlbums = new Set();
            filePaths =
                this.selMgr.getCachedAlbumPaths(
                    hit.album.ID,
                ) ?? [];
            isSingleAlbum = true;
        }

        if (filePaths.length === 0) {
            // Cache miss — cancel the drag.
            e.preventDefault();

            return;
        }

        setDragPayload(e, {
            filePaths,
            source: 'cover-grid',
        });

        // Single album: show its cover, badged with how many tracks are
        // on the way -- an album is 1 track or 30 and the thumbnail is
        // the same picture either way, so the number the drop is about
        // was the one thing this drag did not say.
        // Multiple albums: show the track-count badge alone.
        if (isSingleAlbum && hit.album.CoverArtPath) {
            this.dragImageEl =
                createAlbumArtDragImage(
                    this.getCoverUrl(hit.album),
                    filePaths.length,
                );
        } else {
            this.dragImageEl = createDragImage(
                filePaths.length,
            );
        }

        e.dataTransfer?.setDragImage(
            this.dragImageEl,
            0,
            0,
        );

        emitDragActive(true);
    };

    private onAlbumDragEnd = () => {
        if (this.dragImageEl) {
            removeDragImage(this.dragImageEl);
            this.dragImageEl = null;
        }

        emitDragActive(false);
    };

    /* ====================================================================
     * Grid click (empty area)
     * ==================================================================== */

    private onGridClick = (e: MouseEvent) => {
        for (const el of e.composedPath()) {
            if (!(el instanceof HTMLElement)) continue;

            if (
                el.classList.contains('album-card') ||
                el.classList.contains('album-dropdown')
            ) {
                return;
            }
        }

        this.selectedAlbums = new Set();
        this.lastSelectedAlbumIndex = null;
        this.expandedAlbumId = null;
        this.expandedTracks = [];
        this.selectedTracks = new Set();
        this.lastSelectedTrackIndex = null;
    };

    /* ====================================================================
     * Context menu actions
     * ==================================================================== */

    private async onContextMenuAction(action: string) {
        let filePaths: string[];
        let source: QueueSource | undefined;

        if (this.contextMenuTarget.kind === 'track') {
            filePaths =
                this.selMgr.getSelectedTrackFilePaths(
                    this.selectedTracks,
                    this.expandedTracks,
                );

            const album = this.albums.find(
                (a) => a.ID === this.expandedAlbumId,
            );

            source = album ? this.albumSource(album) : undefined;
        } else {
            filePaths =
                await this.selMgr.getContextMenuAlbumFilePaths(
                    this.contextMenuAlbumId,
                    this.selectedAlbums,
                );

            // A single targeted album has an unambiguous source; a
            // multi-album selection does not.
            if (this.selectedAlbums.size <= 1) {
                const album = this.albums.find(
                    (a) => a.ID === this.contextMenuAlbumId,
                );

                source = album ? this.albumSource(album) : undefined;
            }
        }

        if (filePaths.length === 0) return;

        switch (action) {
            case 'play':
                // One track row is a position in the expanded album, so
                // it queues that album from there - the same thing
                // double-clicking the row does. Anything else (several
                // rows, or an album card) is already an explicit choice
                // of exactly what to play.
                if (
                    this.contextMenuTarget.kind === 'track' &&
                    filePaths.length === 1
                ) {
                    const start = this.expandedTracks.findIndex(
                        (t) => t.FilePath === filePaths[0],
                    );

                    if (start >= 0) {
                        queueStore.setQueue(
                            this.expandedTracks.map(
                                (t) => t.FilePath,
                            ),
                            start,
                            false,
                            source,
                        );

                        break;
                    }
                }

                queueStore.setQueue(filePaths, 0, true, source);
                break;
            case 'add-to-queue':
                queueStore.addTracksToQueue(filePaths);
                break;
            case 'play-next':
                queueStore.playTracksNext(filePaths);
                break;
            case 'track-details':
                if (filePaths.length === 1) {
                    void this.openTrackDetails(filePaths[0]!);
                } else {
                    void this.openBatchTrackDetails(filePaths);
                }
                break;
        }

        this.clearContextMenuSelection();
        this.ctxMenu.close();
    }

    private async onContextMenuFavoriteToggle() {
        const filePaths =
            await this.getPlaylistSubmenuFilePaths();

        if (filePaths.length === 0) return;

        if (this.favCtrl.allFavorited(filePaths)) {
            void this.favCtrl.removeFromFavorites(
                filePaths,
            );
        } else {
            void this.favCtrl.addToFavorites(
                filePaths,
            );
        }

        this.clearContextMenuSelection();
        this.ctxMenu.close();
    }

    /** Clear the selection that was active for the context menu. */
    private clearContextMenuSelection() {
        if (this.contextMenuTarget.kind === 'track') {
            this.selectedTracks = new Set();
        } else {
            this.selectedAlbums = new Set();
        }
    }

    private async openTrackDetails(filePath: string) {
        const track = tracksByFilePath(
            this.expandedTracks,
        ).get(filePath);

        if (!track) return;

        const ready = await loadTrackDetails(
            () => void this.openTrackDetails(filePath),
        );

        if (!ready) return;

        const coverArt =
            this.selMgr.resolveTrackCoverArt(
                track.Album,
                this.expandedAlbumId,
            );

        this.trackDetailsDialog?.show(
            track,
            coverArt ?? undefined,
        );
    }

    private async openBatchTrackDetails(
        filePaths: string[],
    ) {
        const tracks = tracksForPaths(
            this.expandedTracks,
            filePaths,
        );

        if (tracks.length === 0) return;

        const ready = await loadTrackDetails(
            () => void this.openBatchTrackDetails(filePaths),
        );

        if (!ready) return;

        const albumNames = new Set(
            tracks.map((t) => t.Album),
        );
        let coverArt: CoverArtUrls | null = null;
        let coverArtMixed = false;

        if (albumNames.size === 1) {
            const albumName = [...albumNames][0]!;
            coverArt =
                this.selMgr.resolveTrackCoverArt(
                    albumName,
                    this.expandedAlbumId,
                );
        } else {
            coverArtMixed = true;
        }

        this.trackDetailsDialog?.showBatch(
            tracks,
            coverArt,
            coverArtMixed,
        );
    }

    /** Resolve file paths for the playlist submenu. */
    private async getPlaylistSubmenuFilePaths(): Promise<
        string[]
    > {
        if (this.contextMenuTarget.kind === 'track') {
            return this.selMgr.getSelectedTrackFilePaths(
                this.selectedTracks,
                this.expandedTracks,
            );
        }

        return this.selMgr.getContextMenuAlbumFilePaths(
            this.contextMenuAlbumId,
            this.selectedAlbums,
        );
    }

    /* ====================================================================
     * Render: sort toolbar
     * ==================================================================== */

    /** Render the sort toolbar above the grid. */
    /**
     * The page header: title, count, sort, and what the header search
     * box is filtering by.  This was a hand-rolled toolbar written out
     * twice — the other copy was in `track-list` — which is how Albums
     * and Tracks came to have a sort control while Artists and Genres
     * had none (H-19).
     *
     * `externalAlbums` means this grid is a section of the artist page,
     * which has a heading of its own; there it keeps the count and the
     * sort and drops the title.
     */
    private renderPageHeader() {
        return html`
            <page-header
                heading=${this.externalAlbums === undefined ? 'Albums' : ''}
                .count=${this.cachedFilteredAlbums.length}
                count-noun="album"
                .sortOptions=${ALBUM_SORT_OPTIONS.map((o) => ({
                    id: o.id,
                    label: o.label,
                }))}
                sort-field=${this.sortField}
                sort-direction=${this.sortDirection}
                search-term=${this.searchCtrl.term}
                @sort-change=${this.onPageHeaderSort}
            ></page-header>
        `;
    }

    private onPageHeaderSort = (
        e: CustomEvent<{ field: string; direction: SortDirection }>,
    ) => {
        this.sortField = e.detail.field as AlbumSortField;
        this.sortDirection = e.detail.direction;
        this.saveSortPreferences();
    };

    /* ====================================================================
     * Rendering helpers
     * ==================================================================== */

    private getAlbumInitial(name: string): string {
        return name.charAt(0).toUpperCase();
    }

    /**
     * Pick the appropriate cover art URL based on the
     * current card size and device pixel ratio.
     */
    private getCoverUrl(album: library.Album): string {
        const needed =
            this.imageSize * window.devicePixelRatio;

        if (needed <= 100) {
            return (
                album.CoverArtSmall ||
                album.CoverArtMedium ||
                album.CoverArtPath
            );
        }

        if (needed <= 200) {
            return (
                album.CoverArtMedium ||
                album.CoverArtLarge ||
                album.CoverArtPath
            );
        }

        if (needed <= 400) {
            return (
                album.CoverArtLarge ||
                album.CoverArtPath
            );
        }

        return album.CoverArtPath;
    }

    /* ====================================================================

    /* ====================================================================
     * Render: grid entry (virtualizer renderItem)
     * ==================================================================== */

    private renderGridEntry = (
        entry: GridEntry,
    ) => {
        return this.renderAlbumCard(
            entry.album,
            entry.albumIndex,
        );
    };

    /* ====================================================================
     * Render: album card
     *
     * No per-card event listeners — events are delegated
     * via data-index on the virtualizer.
     * ==================================================================== */

    private renderAlbumCard(
        album: library.Album,
        index: number,
    ) {
        const selected = this.selectedAlbums.has(
            album.ID,
        );
        const expanded =
            this.expandedAlbumId === album.ID;

        const classes = [
            'album-card',
            selected ? 'selected' : '',
            expanded ? 'expanded' : '',
        ]
            .filter(Boolean)
            .join(' ');

        const imgSize = this.imageSize;

        return html`
            <div
                class=${classes}
                tabindex=${this.roving.tabIndexFor(index)}
                @focus=${() => this.roving.noteFocus(index)}
                role="option"
                aria-selected=${this.selectedAlbums.has(album.ID)}
                data-index=${index}
                aria-label="${album.Name} by ${album.ArtistName}"
                draggable="true"
                @pointerdown=${this.onAlbumPointerDown}
                @dragstart=${this.onAlbumDragStart}
                @dragend=${this.onAlbumDragEnd}
            >
                <div class="cover-container">
                    ${album.CoverArtPath
                ? html`<img
                              class="cover-image"
                              src="${this.getCoverUrl(album)}"
                              alt="${album.Name} cover"
                              width="${imgSize}"
                              height="${imgSize}"
                              loading="lazy"
                              decoding="async"
                          />`
                : html`<div
                              class="placeholder-cover"
                          >
                              ${this.getAlbumInitial(album.Name)}
                          </div>`}
                </div>
                <div class="album-info">
                    <div
                        class="album-name"
                        title="${album.Name}"
                    >
                        <span class="album-title">${album.Name}</span
                        >${album.Year
                ? html`<span class="album-year"
                                  >(${album.Year})</span
                              >`
                : nothing}
                    </div>
                    <div
                        class="artist-name"
                        title="${album.ArtistName}"
                    >
                        ${creditLink(creditStore.credits(album.MBID), album.ArtistName, album.ArtistMBID ?? '')}
                    </div>
                </div>
            </div>
        `;
    }

    /* ====================================================================
     * Render: main
     * ==================================================================== */

    override render() {
        if (this.loading) {
            return html`<div class="loading">
                Loading albums...
            </div>`;
        }

        if (this.albums.length === 0) {
            return html`
                <div class="empty-state">
                    <p>No albums found</p>
                    <p>
                        Add music to your library to see
                        album covers here.
                    </p>
                </div>
            `;
        }

        if (this.cachedFilteredAlbums.length === 0) {
            return html`
                ${this.renderPageHeader()}
                <div class="empty-state">
                    <p>No albums match your search.</p>
                </div>
            `;
        }

        // The split path draws the dropdown between two grids. Until
        // this was wired up, `render()` ignored `splitMode` entirely:
        // pressing Enter on an album card fetched its tracks over the
        // IPC, ran the whole split state machine (`splitMode: true`,
        // `splitIndex: 6`, measured against the real container) and
        // then drew the single grid regardless, so the only route from
        // the albums grid to a track was the plain click that
        // navigates away to the catalog page.
        const gridContent =
            this.splitMode && this.expandedTracks.length > 0
                ? this.renderSplitGrid()
                : this.renderSingleGrid();

        return html`
            ${this.renderPageHeader()}
            <div
                class="grid-scroll-container"
                @click=${this.onGridClick}
                @keydown=${this.roving.handleKeydown}
            >
                ${gridContent}
            </div>

            ${this.renderContextMenu()}
        `;
    }

    /** Single virtualizer — no dropdown open. */
    private renderSingleGrid() {
        return html`
            <lit-virtualizer
                id="grid-single"
                role="listbox"
                aria-label="Albums"
                aria-multiselectable="true"
                .items=${this.buildGridEntries()}
                .renderItem=${this.renderGridEntry}
                .keyFunction=${(entry: GridEntry) => entry.album.ID}
                .layout=${this.gridLayout}
                @click=${this.onGridAlbumClick}
                @dblclick=${this.onGridAlbumDblClick}
                @keydown=${this.onGridAlbumKeydown}
                @contextmenu=${this.onGridAlbumContextMenu}
                @visibilityChanged=${this.onVisibilityChanged}
            ></lit-virtualizer>
        `;
    }

    /**
     * Dual virtualizer — dropdown sandwiched between the "before" and
     * "after" halves of the grid.
     *
     * Both halves carry the same listbox semantics as the single grid:
     * they are one control to the user, and a selection that spans the
     * dropdown must be announced the same way on either side of it.
     */
    private renderSplitGrid() {
        const sm = this.scrollMgr;
        const ctr = this.scrollContainer;
        const containerW = sm.getContainerWidth(ctr);
        const rowW = sm.getGridRowWidth(ctr);
        const afterEntries = this.getAfterEntries();

        return html`
            <lit-virtualizer
                id="grid-before"
                role="listbox"
                aria-label="Albums"
                aria-multiselectable="true"
                .items=${this.getBeforeEntries()}
                .renderItem=${this.renderGridEntry}
                .keyFunction=${(entry: GridEntry) => entry.album.ID}
                .layout=${this.gridLayout}
                @click=${this.onGridAlbumClick}
                @dblclick=${this.onGridAlbumDblClick}
                @keydown=${this.onGridAlbumKeydown}
                @contextmenu=${this.onGridAlbumContextMenu}
                @visibilityChanged=${this.onVisibilityChanged}
            ></lit-virtualizer>

            <album-dropdown
                .tracks=${this.expandedTracks}
                .selectedTracks=${this.selectedTracks}
                .containerWidth=${containerW}
                .gridRowWidth=${rowW}
                .caratOffset=${sm.getCaratOffset(ctr)}
                style="margin-left:${(containerW - rowW) / 2}px"
                @track-click=${this.onTrackClick}
                @track-dblclick=${this.onTrackDblClick}
                @track-contextmenu=${this.onTrackContextMenu}
                @track-dragstart=${this.onTrackDragStart}
                @track-dragend=${this.onTrackDragEnd}
            ></album-dropdown>

            ${afterEntries.length > 0
                ? html`
                      <lit-virtualizer
                          id="grid-after"
                          role="listbox"
                          aria-label="Albums, continued"
                          aria-multiselectable="true"
                          .items=${afterEntries}
                          .renderItem=${this.renderGridEntry}
                          .keyFunction=${(entry: GridEntry) => entry.album.ID}
                          .layout=${this.gridLayoutAfter}
                          @click=${this.onGridAlbumClick}
                          @dblclick=${this.onGridAlbumDblClick}
                          @keydown=${this.onGridAlbumKeydown}
                          @contextmenu=${this.onGridAlbumContextMenu}
                      ></lit-virtualizer>
                  `
                : nothing}
        `;
    }


    /** Context menu + playlist submenu popups. */
    /** Trigger playlist submenu with resolved file paths. */
    private async handleShowPlaylistSubmenu() {
        const filePaths =
            await this.getPlaylistSubmenuFilePaths();

        void this.ctxMenu.showPlaylistSubmenu(
            filePaths,
        );
    }

    private renderContextMenu() {
        const { ctxMenu } = this;

        return html`
            <wa-popup
                id="context-menu"
                placement="bottom-start"
                flip
                shift
                .active=${ctxMenu.contextMenuOpen}
            >
                ${ctxMenu.contextMenuOpen
                ? html`
                          <div
                              class="context-menu-panel"
                              role="menu"
                              aria-label=${this.contextMenuTarget.kind === 'track'
                        ? 'Track actions'
                        : 'Album actions'}
                          >
                              <wa-dropdown-item
                                  @click=${() =>
                        this.onContextMenuAction(
                            'play',
                        )}
                                  @mouseenter=${() =>
                        ctxMenu.closePlaylistSubmenu()}
                              >
                                  <wa-icon
                                      slot="icon"
                                      name="play"
                                  ></wa-icon>
                                  Play
                              </wa-dropdown-item>
                              <wa-dropdown-item
                                  @click=${() =>
                        this.onContextMenuAction(
                            'add-to-queue',
                        )}
                                  @mouseenter=${() =>
                        ctxMenu.closePlaylistSubmenu()}
                              >
                                  <wa-icon
                                      slot="icon"
                                      name=${ICON_QUEUE}
                                  ></wa-icon>
                                  Add to Queue
                              </wa-dropdown-item>
                              <wa-dropdown-item
                                  @click=${() =>
                        this.onContextMenuAction(
                            'play-next',
                        )}
                                  @mouseenter=${() =>
                        ctxMenu.closePlaylistSubmenu()}
                              >
                                  <wa-icon
                                      slot="icon"
                                      name="forward-step"
                                  ></wa-icon>
                                  Play Next
                              </wa-dropdown-item>
                              <wa-dropdown-item
                                  class="submenu-item"
                                  @mouseenter=${() => {
                        ctxMenu.clearSubmenuCloseTimer();
                        void this.handleShowPlaylistSubmenu();
                    }}
                                  @mouseleave=${ctxMenu
                        .scheduleSubmenuClose}
                                  @click=${(e: Event) => {
                        e.stopPropagation();
                        void this.handleShowPlaylistSubmenu();
                    }}
                              >
                                  <wa-icon
                                      slot="icon"
                                      name=${ICON_PLAYLIST}
                                  ></wa-icon>
                                   Add to Playlist
                                   <span
                                       class="submenu-arrow"
                                       >&#9654;</span
                                   >
                              </wa-dropdown-item>
                              <wa-dropdown-item
                                  @click=${() =>
                                      this.onContextMenuFavoriteToggle()}
                                  @mouseenter=${() =>
                                      ctxMenu.closePlaylistSubmenu()}
                              >
                                   <wa-icon
                                       slot="icon"
                                       name=${this.favCtrl.iconName}
                                   ></wa-icon>
                                  ${this.favCtrl.allFavorited(this.ctxMenu.playlistFilePaths) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
                              </wa-dropdown-item>
                              ${this.contextMenuTarget
                                      .kind ===
                                  'track'
                                  ? html`
                                        <wa-dropdown-item
                                            @click=${() =>
                                                this.onContextMenuAction(
                                                    'track-details',
                                                )}
                                            @mouseenter=${() =>
                                                ctxMenu.closePlaylistSubmenu()}
                                        >
                                            <wa-icon
                                                slot="icon"
                                                name="circle-info"
                                            ></wa-icon>
                                            Track
                                            Details
                                        </wa-dropdown-item>
                                    `
                                  : nothing}
                          </div>
                      `
                : nothing}
            </wa-popup>

            <wa-popup
                id="playlist-submenu"
                placement="right-start"
                flip
                shift
                .active=${ctxMenu.playlistSubmenuOpen}
            >
                ${ctxMenu.playlistSubmenuOpen
                ? html`
                          <div
                              @mouseenter=${() =>
                        ctxMenu.clearSubmenuCloseTimer()}
                              @mouseleave=${ctxMenu
                        .scheduleSubmenuClose}
                          >
                              <playlist-picker
                                  .filePaths=${ctxMenu
                        .playlistFilePaths}
                                  @playlist-action-complete=${ctxMenu
                        .onPlaylistActionComplete}
                                  @click=${(e: Event) =>
                        e.stopPropagation()}
                              ></playlist-picker>
                          </div>
                      `
                : nothing}
            </wa-popup>

            <track-details></track-details>
        `;
    }
}

declare global {
    interface HTMLElementTagNameMap {
        'cover-grid': CoverGrid;
    }
}
