import { LitElement, html, css, nothing } from 'lit';
import {
    customElement,
    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 { gridSpacingFor } from '@utils/grid-spacing';
import {
    GetFilePathsByGenres,
} from '@go/library/library.js';
import type * as library from '@go/library/models.js';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
import { queueStore } from '@store/queue-store';
import {
    ContextMenuController,
    contextMenuStyles,
    isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';

import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dictByName } from '@utils/binding';
import {
    ICON_PLAYLIST,
    ICON_QUEUE,
} from '@utils/icon-language';

/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;

/** localStorage key for persisted genre card size. */
const CARD_SIZE_KEY = 'genres-view-card-size';

/** Card size limits. */
const CARD_SIZE_MIN = 100;
const CARD_SIZE_MAX = 350;
const CARD_SIZE_DEFAULT = 176;

/** Debounce delay for saving scroll position. */
const SCROLL_DEBOUNCE_MS = 100;

/** A genre extracted from the track library. */
const GENRE_SORT_KEY = 'genres-view-sort';

const GENRE_SORT_OPTIONS = [
    { id: 'name', label: 'Name' },
    { id: 'tracks', label: 'Tracks' },
];

interface Genre {
    name: string;
    trackCount: number;
}

/** Grid entry for the virtualized genre grid. */
interface GenreEntry {
    genre: Genre;
    index: number;
}

@customElement('genres-view')
export class GenresView
    extends ViewLifecycleMixin(LitElement)
    implements ContextMenuHost
{
    private libraryCtrl = new LibraryController(this);
    private searchCtrl = new SearchController(this);
    private ctxMenu = new ContextMenuController(this);
    private favCtrl = new FavoritesController(this);
    private wheelListenerAttached = false;
    private lastSearchTerm = '';

    /** See artists-view: one tab stop for the grid, moved with the
     *  arrow keys. */
    private roving = new RovingGridController(this, {
        cardSelector: '.genre-card',
        count: () => this.cachedGridEntries.length,
        scrollToIndex: (index) => {
            this.shadowRoot
                ?.querySelector<LitVirtualizer>('lit-virtualizer')
                ?.scrollToIndex(index, 'nearest');
        },
    });

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

    private scrollDebounceTimer: ReturnType<
        typeof setTimeout
    > | null = null;

    @state()
    private genres: Genre[] = [];

    @state()
    private loading = true;

    @state()
    private restoringScroll = false;

    @state()
    private cardSize: number = CARD_SIZE_DEFAULT;

    // ----- Multi-select state -----

    @state()
    private selectedGenres: Set<string> = new Set();

    private lastSelectedGenreIndex: number | null =
        null;

    // ----- Context menu state -----

    /**
     * Genre name that was right-clicked to open the
     * context menu.  Used as fallback when the
     * right-clicked genre is not in the current
     * visual selection.
     */
    private contextMenuGenreName: string | null = null;

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

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

    // ----- ContextMenuHost interface -----

    getContextMenuPopup(): MenuTarget | undefined {
        return this.contextMenuPopup;
    }

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

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

    // ----- Grid spacing constants -----

    private static readonly CARD_PADDING = 5;

    private get imageSize(): number {
        return (
            this.cardSize -
            GenresView.CARD_PADDING * 2
        );
    }

    private get cardTextHeight(): number {
        const w = this.cardSize;

        if (w < 160) return 30;
        if (w > 250) return 42;

        return 36;
    }

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

    private gridLayout = this.createGridLayout();

    private createGridLayout() {
        const w = this.cardSize ?? CARD_SIZE_DEFAULT;
        const h = w + this.cardTextHeight;

        // One number for the gap, the row gap and the padding: whatever
        // a row could not spend on another card, shared out equally, so
        // the outside is never wider than the inside.  See
        // `utils/grid-spacing.ts`.
        const spacing = this.spacingFor(this.containerWidth);

        this.lastLayoutSpacing = spacing;

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

    /** The width the grid lays itself out in. */
    private get containerWidth(): number {
        return (
            this.renderRoot?.querySelector<HTMLElement>(
                '.grid-scroll-container',
            )?.clientWidth ||
            this.clientWidth ||
            0
        );
    }

    private spacingFor(width: number): number {
        return gridSpacingFor(width, this.cardSize);
    }

    /** Sort key and direction for the genre grid (H-19: it had none). */
    @state()
    private sortField: 'name' | 'tracks' = 'name';

    @state()
    private sortDirection: 'asc' | 'desc' = 'asc';

    // -- Memoisation caches for filtered genres --
    private cachedFilteredGenres: Genre[] = [];
    private cachedGridEntries: GenreEntry[] = [];
    private prevFilterGenres: Genre[] = [];
    private prevFilterTerm = '';
    private prevFilterSort = '';

    /**
     * Recompute the filtered-genres and grid-entries
     * caches when their inputs have changed.  Called
     * from willUpdate() so the caches are ready
     * before render().
     */
    private recomputeGenreCaches() {
        const term = this.searchCtrl.term;

        const sortKey = `${this.sortField}:${this.sortDirection}`;

        if (
            this.genres !== this.prevFilterGenres ||
            term !== this.prevFilterTerm ||
            sortKey !== this.prevFilterSort
        ) {
            this.prevFilterGenres = this.genres;
            this.prevFilterTerm = term;
            this.prevFilterSort = sortKey;
            this.cachedFilteredGenres =
                this.computeFilteredGenres();
            this.cachedGridEntries =
                this.cachedFilteredGenres.map(
                    (genre, index) => ({
                        genre,
                        index,
                    }),
                );
        }
    }

    private computeFilteredGenres(): Genre[] {
        const term =
            this.searchCtrl.term.toLowerCase();

        const matching = term
            ? this.genres.filter((g) =>
                  g.name.toLowerCase().includes(term),
              )
            : this.genres;

        // The default order is the backend's, and the array's identity
        // is what makes the virtualizer repaint — so leave it alone
        // unless the user asked for something else.
        if (this.sortField === 'name' && this.sortDirection === 'asc') {
            return matching;
        }

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

        return [...matching].sort((a, b) =>
            this.sortField === 'tracks'
                ? dir * (a.trackCount - b.trackCount)
                : dir * a.name.localeCompare(b.name),
        );
    }

    private onPageHeaderSort = (
        e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
    ) => {
        this.sortField =
            e.detail.field === 'tracks' ? 'tracks' : 'name';
        this.sortDirection = e.detail.direction;

        try {
            localStorage.setItem(
                GENRE_SORT_KEY,
                `${this.sortField}:${this.sortDirection}`,
            );
        } catch {
            // Ignore storage errors.
        }
    };

    private loadSortPreferences() {
        try {
            const saved = localStorage.getItem(GENRE_SORT_KEY);
            const [field, dir] = (saved ?? '').split(':');

            if (field === 'name' || field === 'tracks') {
                this.sortField = field;
            }

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

    static override styles = [
        contextMenuStyles,
        css`
        :host {
            display: flex;
            flex-direction: column;
            overflow: hidden;
            height: 100%;
            position: relative;
            contain: layout style;
        }

        .grid-scroll-container {
            flex: 1;
            overflow-y: auto;
            overflow-x: hidden;
            contain: paint;
        }

        lit-virtualizer {
            width: 100%;
            min-height: 100%;
        }

        .genre-card {
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 5px;
            border-radius: 8px;
            cursor: pointer;
            /* transitions removed — software rendering repaints per frame */
            overflow: hidden;
        }

        .genre-card:hover {
            background-color: var(
                --yj-bg-overlay,
                rgba(255, 255, 255, 0.06)
            );
        }

        .genre-card:active {
            transform: scale(0.97);
        }

        .genre-card.selected {
            outline: 2px solid
                var(--yj-accent, #ffd43b);
            outline-offset: 2px;
        }

        .genre-card.selected .avatar-container {
            scale: 0.95;
        }

        .genre-card.selected .genre-name {
            scale: 0.95;
        }

        .avatar-container {
            width: var(--avatar-size);
            height: var(--avatar-size);
            border-radius: 8px;
            overflow: hidden;
            background: linear-gradient(
                135deg,
                var(--yj-bg-overlay, #404040) 0%,
                var(--yj-bg-surface, #282828) 100%
            );
            display: flex;
            align-items: center;
            justify-content: center;
            flex-shrink: 0;
        }

        .avatar-placeholder {
            color: var(
                --yj-text-secondary,
                #b3b3b3
            );
            font-size: var(
                --placeholder-font,
                48px
            );
            font-weight: 600;
            text-transform: uppercase;
            user-select: none;
            line-height: 1;
        }

        .genre-name {
            width: 100%;
            text-align: center;
            font-size: var(
                --genre-name-font,
                14px
            );
            font-weight: 500;
            color: var(--yj-text-primary, #fff);
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            padding: var(--genre-name-pad, 6px) 2px
                0;
            line-height: 1.3;
        }

        .search-bar-row {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 30px;
            border-bottom: 1px solid
                var(--yj-border-subtle, #333);
            flex-shrink: 0;
            user-select: none;
        }

            .search-indicator {
                position: absolute;
                left: 50%;
                transform: translateX(-50%);
                pointer-events: none;
            background: var(
                --yj-bg-overlay,
                #495057
            );
            color: var(
                --yj-text-secondary,
                #b3b3b3
            );
            font-size: 12px;
            padding: 2px 14px;
            border-radius: 12px;
            border: 1px solid
                var(--yj-border-subtle, #555);
            white-space: nowrap;
            opacity: 0.92;
        }

        .loading-message,
        .empty-message {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            color: var(
                --yj-text-secondary,
                #b3b3b3
            );
            font-size: 14px;
        }

    `,
    ];

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

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

    override connectedCallback() {
        super.connectedCallback();
        this.loadCardSize();
        this.loadSortPreferences();
        this.loadGenres();
    }

    override disconnectedCallback() {
        super.disconnectedCallback();
        this.detachWheelListener();
        this.gridResizeObserver?.disconnect();
        this.gridResizeObserver = null;
    }

    /** See artists-view: off-screen the grid cannot be scrolled, and
     *  being cached it is never disconnected. */
    protected override onViewDeactivate(): void {
        this.detachWheelListener();

        if (this.scrollDebounceTimer !== null) {
            clearTimeout(this.scrollDebounceTimer);
            this.scrollDebounceTimer = null;
        }
    }

    override updated() {
        this.updateSizeProperties();
        this.ensureWheelListener();
        this.updateGridLayout();

        // Clear selection when search term changes.
        const currentTerm = this.searchCtrl.term;

        if (currentTerm !== this.lastSearchTerm) {
            this.lastSearchTerm = currentTerm;
            this.clearSelection();
        }

        // Re-fetch when the store delivers fresh
        // data after eager refetch on invalidation.
        const cached =
            this.libraryCtrl.cachedGenres;

        if (
            cached !== null &&
            cached !== this.lastGenresRef
        ) {
            this.lastGenresRef = cached;
            this.loadGenres();
        }
    }

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

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

            const rows =
                await this.libraryCtrl.getGenres();

            this.genres = (rows ?? []).map((r) => ({
                name: r.Name,
                trackCount: r.TrackCount,
            }));
        } catch (error) {
            console.error(
                'Error loading genres:',
                error,
            );
            this.genres = [];
        } finally {
            const saved =
                this.libraryCtrl.getScrollPosition(
                    'genres',
                );

            this.restoringScroll = saved > 0;
            this.loading = false;
        }

        await this.updateComplete;
        this.restoreScrollPosition();
    }

    /* ================================================================
     * Scroll position persistence
     * ================================================================ */

    /**
     * Save the first visible item index on scroll.
     */
    private onVisibilityChanged = (
        e: VisibilityChangedEvent,
    ) => {
        if (this.restoringScroll) return;

        if (this.scrollDebounceTimer !== null) {
            clearTimeout(this.scrollDebounceTimer);
        }

        this.scrollDebounceTimer = setTimeout(
            () => {
                this.libraryCtrl.setScrollPosition(
                    'genres',
                    e.first,
                );
            },
            SCROLL_DEBOUNCE_MS,
        );
    };

    /**
     * Restore scroll position from the store.
     */
    private restoreScrollPosition(): void {
        const saved =
            this.libraryCtrl.getScrollPosition(
                'genres',
            );

        if (saved <= 0) {
            this.restoringScroll = false;

            return;
        }

        const virt =
            this.shadowRoot?.querySelector(
                'lit-virtualizer',
            ) as LitVirtualizer | null;

        if (!virt) {
            this.restoringScroll = false;

            return;
        }

        const safeIndex = Math.min(
            saved,
            this.cachedFilteredGenres.length - 1,
        );

        if (safeIndex <= 0) {
            this.restoringScroll = false;

            return;
        }

        virt.scrollToIndex(safeIndex, 'start');
        this.restoringScroll = false;
    }

    /* ================================================================
     * Card size (zoom)
     * ================================================================ */

    private loadCardSize(): void {
        try {
            const stored =
                localStorage.getItem(CARD_SIZE_KEY);

            if (stored !== null) {
                const parsed = parseInt(stored, 10);

                if (!Number.isNaN(parsed)) {
                    this.cardSize = Math.max(
                        CARD_SIZE_MIN,
                        Math.min(
                            CARD_SIZE_MAX,
                            parsed,
                        ),
                    );
                }
            }
        } catch {
            // localStorage may be unavailable.
        }
    }

    private saveCardSize(): void {
        try {
            localStorage.setItem(
                CARD_SIZE_KEY,
                String(this.cardSize),
            );
        } catch {
            // localStorage may be unavailable.
        }
    }

    private setCardSize(size: number): void {
        const clamped = Math.round(
            Math.max(
                CARD_SIZE_MIN,
                Math.min(CARD_SIZE_MAX, size),
            ),
        );

        if (clamped === this.cardSize) return;

        this.cardSize = clamped;
        this.saveCardSize();
    }

    /* ================================================================
     * Wheel zoom (Ctrl+scroll)
     * ================================================================ */

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

        e.preventDefault();

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

        this.setCardSize(this.cardSize + delta);
    }

    private ensureWheelListener() {
        const container =
            this.shadowRoot?.querySelector(
                '.grid-scroll-container',
            );

        if (
            container &&
            !this.wheelListenerAttached
        ) {
            container.addEventListener(
                'wheel',
                this
                    .wheelHandler as EventListener,
                { passive: false },
            );
            this.wheelListenerAttached = true;
        }
    }

    private detachWheelListener() {
        const container =
            this.shadowRoot?.querySelector(
                '.grid-scroll-container',
            );

        if (
            container &&
            this.wheelListenerAttached
        ) {
            container.removeEventListener(
                'wheel',
                this
                    .wheelHandler as EventListener,
            );
            this.wheelListenerAttached = false;
        }
    }

    /* ================================================================
     * Grid layout
     * ================================================================ */

    private lastLayoutWidth = 0;
    private lastLayoutSpacing = 0;

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

    private observeGridWidth() {
        const container =
            this.renderRoot?.querySelector<HTMLElement>(
                '.grid-scroll-container',
            );

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

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

    private updateGridLayout() {
        this.observeGridWidth();

        if (
            this.cardSize === this.lastLayoutWidth &&
            this.lastLayoutSpacing ===
                this.spacingFor(this.containerWidth)
        ) {
            return;
        }

        this.lastLayoutWidth = this.cardSize;
        this.gridLayout = this.createGridLayout();
    }

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

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

        if (w < 160) {
            this.style.setProperty(
                '--genre-name-font',
                '12px',
            );
            this.style.setProperty(
                '--genre-name-pad',
                '4px',
            );
        } else if (w > 250) {
            this.style.setProperty(
                '--genre-name-font',
                '15px',
            );
            this.style.setProperty(
                '--genre-name-pad',
                '8px',
            );
        } else {
            this.style.setProperty(
                '--genre-name-font',
                '14px',
            );
            this.style.setProperty(
                '--genre-name-pad',
                '6px',
            );
        }
    }

    /* ================================================================
     * Genre selection helpers
     * ================================================================ */

    /**
     * Select a contiguous range of genre names
     * between two indices in filteredGenres.
     */
    private selectGenreRange(
        from: number,
        to: number,
    ): Set<string> {
        const filtered = this.cachedFilteredGenres;
        const start = Math.min(from, to);
        const end = Math.max(from, to);
        const names = new Set<string>();

        for (let i = start; i <= end; i++) {
            const genre = filtered[i];

            if (genre) {
                names.add(genre.name);
            }
        }

        return names;
    }

    /**
     * Fetch file paths for a set of genre names by
     * querying the backend for each genre.
     * Respects the active library filter.
     */
    private async getFilePathsForGenres(
        genreNames: Iterable<string>,
    ): Promise<string[]> {
        const seen = new Set<string>();
        const allPaths: string[] = [];
        const libId =
            this.libraryCtrl.selectedLibraryId;

        // perf.m2: one call per genre, each returning
        // whole track rows so the file path could be
        // read off them — 6 MB over the IPC for five
        // genres of a 50 000-track library.
        const names = Array.from(genreNames);
        const byGenre = await dictByName(
            GetFilePathsByGenres(names, libId ?? 0),
        );

        // Still de-duplicated here: a track with two of
        // the selected genres appears under both, and
        // the caller owns the order.
        for (const name of names) {
            for (const path of byGenre[name] ?? []) {
                if (!seen.has(path)) {
                    seen.add(path);
                    allPaths.push(path);
                }
            }
        }

        return allPaths;
    }

    /**
     * Return file paths for the context menu target.
     * If the right-clicked genre is part of the
     * current selection, return paths for all selected
     * genres.  Otherwise return paths for the
     * right-clicked genre only.
     */
    private async getContextMenuGenreFilePaths(): Promise<
        string[]
    > {
        if (
            this.contextMenuGenreName !== null &&
            !this.selectedGenres.has(
                this.contextMenuGenreName,
            )
        ) {
            return this.getFilePathsForGenres([
                this.contextMenuGenreName,
            ]);
        }

        return this.getFilePathsForGenres(
            this.selectedGenres,
        );
    }

    /** Clear the current genre selection. */
    private clearSelection() {
        this.selectedGenres = new Set();
        this.lastSelectedGenreIndex = null;
    }

    /* ================================================================
     * Genre card click
     * ================================================================ */

    private onGenreClick(
        e: MouseEvent,
        genre: Genre,
        index: number,
    ) {
        const isCtrl = e.ctrlKey || e.metaKey;
        const isShift = e.shiftKey;

        if (
            isShift &&
            this.lastSelectedGenreIndex !== null
        ) {
            const range = this.selectGenreRange(
                this.lastSelectedGenreIndex,
                index,
            );
            const next = new Set(
                this.selectedGenres,
            );

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

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

            if (next.has(genre.name)) {
                next.delete(genre.name);
            } else {
                next.add(genre.name);
            }

            this.selectedGenres = next;
            this.lastSelectedGenreIndex = index;
        } else {
            // Plain click: navigate to details.
            this.clearSelection();
            this.dispatchEvent(
                new CustomEvent('navigate', {
                    bubbles: true,
                    composed: true,
                    detail: {
                        view: 'genre-details',
                        genreName: genre.name,
                    },
                }),
            );
        }
    }

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

    private onGenreContextMenu = (
        e: MouseEvent,
        genre: Genre,
    ) => {
        e.preventDefault();
        e.stopPropagation();

        this.contextMenuGenreName = genre.name;

        this.ctxMenu.openAt(
            e.clientX,
            e.clientY,
        );
    };

    /** Shift+F10 / ContextMenu on a focused card. */
    private openGenreMenuFromKey(
        e: KeyboardEvent,
        genre: Genre,
    ): void {
        const card = e.currentTarget as HTMLElement | null;

        if (!card) return;

        e.preventDefault();
        e.stopPropagation();
        this.contextMenuGenreName = genre.name;
        this.ctxMenu.openFrom(card);
    }

    private async onContextMenuAction(
        action: string,
    ) {
        const filePaths =
            await this.getContextMenuGenreFilePaths();

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

        switch (action) {
            case 'play':
                queueStore.setQueue(filePaths, 0, true, {
                    type: 'genre',
                    id: 0,
                    label: this.contextMenuGenreName ?? '',
                });
                break;
            case 'add-to-queue':
                queueStore.addTracksToQueue(
                    filePaths,
                );
                break;
            case 'play-next':
                queueStore.playTracksNext(
                    filePaths,
                );
                break;
        }

        this.ctxMenu.close();
    }

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

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

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

        this.ctxMenu.close();
    }

    /* ================================================================
     * Helpers
     * ================================================================ */

    private getGenreInitial(name: string): string {
        if (!name) return '?';

        return name.charAt(0).toUpperCase();
    }

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

    private renderGenreCard(entry: GenreEntry) {
        const { genre, index } = entry;
        const imgSize = this.imageSize;
        const placeholderFont = Math.round(
            imgSize * 0.38,
        );
        const isSelected =
            this.selectedGenres.has(genre.name);

        return html`
            <div
                class="genre-card${isSelected
                    ? ' selected'
                    : ''}"
                data-index=${index}
                tabindex=${this.roving.tabIndexFor(index)}
                @focus=${() => this.roving.noteFocus(index)}
                role="option"
                aria-label="${genre.name}"
                aria-selected="${isSelected}"
                style="
                    --avatar-size: ${imgSize}px;
                    --placeholder-font: ${placeholderFont}px;
                "
                @click=${(e: MouseEvent) =>
                    this.onGenreClick(
                        e,
                        genre,
                        index,
                    )}
                @contextmenu=${(e: MouseEvent) =>
                    this.onGenreContextMenu(
                        e,
                        genre,
                    )}
                @keydown=${(e: KeyboardEvent) => {
                    if (isContextMenuKey(e)) {
                        this.openGenreMenuFromKey(
                            e,
                            genre,
                        );

                        return;
                    }

                    if (
                        e.key === 'Enter' ||
                        e.key === ' '
                    ) {
                        e.preventDefault();
                        this.clearSelection();
                        this.dispatchEvent(
                            new CustomEvent(
                                'navigate',
                                {
                                    bubbles: true,
                                    composed: true,
                                    detail: {
                                        view: 'genre-details',
                                        genreName:
                                            genre.name,
                                    },
                                },
                            ),
                        );
                    }
                }}
            >
                <div class="avatar-container">
                    <span class="avatar-placeholder">
                        ${this.getGenreInitial(
                            genre.name,
                        )}
                    </span>
                </div>
                <div
                    class="genre-name"
                    title="${genre.name}"
                >
                    ${genre.name}
                </div>
            </div>
        `;
    }

    private renderContextMenu() {
        return html`
            <menu-surface
                id="context-menu"
                .active=${this.ctxMenu
                    .contextMenuOpen}
            >
                ${this.ctxMenu.contextMenuOpen
                    ? html`
                          <div
                              class="context-menu-panel"
                              role="menu"
                              aria-label="Genre actions"
                          >
                              <wa-dropdown-item
                                  @click=${() =>
                                      this.onContextMenuAction(
                                          'play',
                                      )}
                                  @mouseenter=${() =>
                                      this.ctxMenu.closePlaylistSubmenu()}
                              >
                                  <wa-icon
                                      slot="icon"
                                      name="play"
                                  ></wa-icon>
                                  Play
                              </wa-dropdown-item>
                              <wa-dropdown-item
                                  @click=${() =>
                                      this.onContextMenuAction(
                                          'add-to-queue',
                                      )}
                                  @mouseenter=${() =>
                                      this.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=${() =>
                                      this.ctxMenu.closePlaylistSubmenu()}
                              >
                                  <wa-icon
                                      slot="icon"
                                      name="forward-step"
                                  ></wa-icon>
                                  Play Next
                              </wa-dropdown-item>
                              <wa-dropdown-item
                                  class="submenu-item"
                                  @mouseenter=${() => {
                                      this.ctxMenu.clearSubmenuCloseTimer();
                                      void this.getContextMenuGenreFilePaths().then(
                                          (paths) =>
                                              this.ctxMenu.showPlaylistSubmenu(
                                                  paths,
                                              ),
                                      );
                                  }}
                                  @mouseleave=${this
                                      .ctxMenu
                                      .scheduleSubmenuClose}
                                  @click=${(
                                      e: Event,
                                  ) => {
                                      e.stopPropagation();
                                      void this.getContextMenuGenreFilePaths().then(
                                          (paths) =>
                                              this.ctxMenu.showPlaylistSubmenu(
                                                  paths,
                                              ),
                                      );
                                  }}
                              >
                                  <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=${() =>
                                      this.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>
                          </div>
                      `
                    : nothing}
            </menu-surface>

            <menu-surface
                id="playlist-submenu"
                label="Add to playlist"
                placement="right-start"
                .active=${this.ctxMenu
                    .playlistSubmenuOpen}
            >
                ${this.ctxMenu.playlistSubmenuOpen
                    ? html`
                          <div
                              @mouseenter=${() =>
                                  this.ctxMenu.clearSubmenuCloseTimer()}
                              @mouseleave=${this
                                  .ctxMenu
                                  .scheduleSubmenuClose}
                          >
                              <playlist-picker
                                  .filePaths=${this
                                      .ctxMenu
                                      .playlistFilePaths}
                                  @playlist-action-complete=${this
                                      .ctxMenu
                                      .onPlaylistActionComplete}
                                  @click=${(
                                      e: Event,
                                  ) =>
                                      e.stopPropagation()}
                              ></playlist-picker>
                          </div>
                      `
                    : nothing}
            </menu-surface>
        `;
    }

    override render() {
        if (this.loading) {
            // The header keeps its place while the view loads: a
            // heading that appears only once the data does is the
            // shifting layout this component exists to stop.
            return html`
                <page-header
                    heading="Genres"
                    .sortOptions=${GENRE_SORT_OPTIONS}
                    sort-field=${this.sortField}
                    sort-direction=${this.sortDirection}
                ></page-header>
                <div class="loading-message">
                    Loading genres...
                </div>
            `;
        }

        const entries = this.cachedGridEntries;
        const searchBar = html`
            <page-header
                heading="Genres"
                .count=${entries.length}
                count-noun="genre"
                .sortOptions=${GENRE_SORT_OPTIONS}
                sort-field=${this.sortField}
                sort-direction=${this.sortDirection}
                search-term=${this.searchCtrl.term}
                @sort-change=${this.onPageHeaderSort}
            ></page-header>
        `;

        if (entries.length === 0) {
            return html`
                ${searchBar}
                <div class="empty-message">
                    ${this.searchCtrl.term
                        ? 'No genres match your search.'
                        : 'No genres in library.'}
                </div>
            `;
        }

        return html`
            ${searchBar}
            <div
                class="grid-scroll-container"
                style=${this.restoringScroll
                    ? 'visibility: hidden'
                    : ''}
                @click=${this.onGridClick}
                @keydown=${this.roving.handleKeydown}
            >
                <lit-virtualizer
                    role="listbox"
                    aria-label="Genres"
                    aria-multiselectable="true"
                    .items=${entries}
                    .renderItem=${(entry: GenreEntry) => this.renderGenreCard(entry)}
                    .keyFunction=${(entry: GenreEntry) => entry.genre.name}
                    .layout=${this.gridLayout}
                    @visibilityChanged=${this.onVisibilityChanged}
                ></lit-virtualizer>
            </div>
            ${this.renderContextMenu()}
        `;
    }

    /**
     * Click on empty area of the grid clears the
     * selection.
     */
    private onGridClick = (e: MouseEvent) => {
        const path = e.composedPath();

        const clickedCard = path.some(
            (el) =>
                el instanceof HTMLElement &&
                el.classList.contains('genre-card'),
        );

        if (!clickedCard) {
            this.clearSelection();
        }
    };
}
