import * as library from '@go/library/models.js';
import { LitElement, html, svg, css, nothing } from 'lit';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import {
    customElement,
    property,
    state,
    query,
} from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints';
import {
    ContextMenuController,
    contextMenuStyles,
    isContextMenuKey,
} from '@utils/context-menu-controller.js';

import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
import type { SortOption } from '@components/page-header/page-header';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import type { QueueSource } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller';
import {
    COLUMN_DEFS,
    DEFAULT_COLUMN_IDS,
    PHONE_COLUMN_IDS,
} from './columns';
import type { ColumnDef } from './columns';
import { classMap } from 'lit/directives/class-map.js';
import {
    rankTracks,
    highlightText,
} from './search-ranking';
import {
    artistLink,
    creditLink,
    albumLink,
    trackLink,
    exploreLinkStyles,
} from '@utils/explore-link';
import {
    setDragPayload,
    emitDragActive,
} from '@utils/drag-controller';
import {
    createDragImage,
    createTrackCardDragImage,
    removeDragImage,
} from '@utils/drag-image';
import '@lit-labs/virtualizer';
import type {
    LitVirtualizer,
    VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.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 '@awesome.me/webawesome/dist/components/icon/icon.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import { RemoveFromLibrary } from '@go/library/library.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import '@components/playlist-picker/playlist-picker.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
    ICON_PLAYLIST,
    ICON_QUEUE,
} from '@utils/icon-language';

const COLUMN_STORAGE_KEY = 'track-list-column-widths';
const SORT_FIELD_KEY = 'track-list-sort-field';
const SORT_DIR_KEY = 'track-list-sort-direction';
const MIN_COLUMN_WIDTH = 50;
const DEFAULT_FIXED_WIDTH = 80;

/**
 * Chrome the resizable columns cannot use: the fixed favourite column
 * and the row's own horizontal padding.  Both numbers also appear in
 * the CSS below — the `24px` first track of `--grid-cols` and
 * `.track-row`/`.header-row`'s `padding: 8px` — so they live here and
 * are read from both places rather than written out four times.
 */
const FAV_COL_WIDTH = 24;
const ROW_PADDING_X = 8;
const ROW_CHROME_WIDTH =
    FAV_COL_WIDTH + ROW_PADDING_X * 2;

/**
 * Row heights, in the same relationship as the widths above: the number
 * is read by the CSS *and* by the virtualizer's layout, so they cannot
 * disagree. A phone row is two lines (title over artist).
 */
const ROW_HEIGHT = 33;
const PHONE_ROW_HEIGHT = 52;


// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
// overhead (30-50 shadow roots during scroll).  Font Awesome 6 paths.
const FAV_ICONS = {
    heart: {
        viewBox: '0 0 512 512',
        regular: 'M225.8 468.2l-2.5-2.3L48.1 303.2C17.4 274.7 0 234.7 0 192.8v-3.3c0-70.4 50-130.8 119.2-144C158.6 37.9 198.9 47 231 69.6c9 6.3 17.3 13.5 25 21.5c7.7-8 16-15.2 25-21.5c32.1-22.6 72.4-31.7 111.8-24.2C461.5 59.6 512 124.2 512 192.8v3.3c0 41.9-17.4 81.9-48.1 110.4L288.7 465.9l-2.5 2.3c-8.2 7.6-19 11.9-30.2 11.9s-22-4.2-30.2-11.9z',
        solid: 'M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z',
    },
    star: {
        viewBox: '0 0 576 512',
        regular: 'M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L434.8 326.7l26.2 155.6c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7L288 439.6 149.7 507.5c-8 4.3-17.8 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L266.3 13.5C270.4 5.2 278.7 0 287.9 0z',
        solid: 'M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 108.4 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3L288.1 439.8 420.9 508.3c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L437.7 329 542 225.9c8.6-8.4 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L380.7 150.3 316.9 18z',
    },
} as const;

type SortDirection = 'asc' | 'desc';

@customElement('track-list')
export class TrackList
    extends ViewLifecycleMixin(LitElement)
    implements SelectionHost, ContextMenuHost
{
    /* Claimed while this list is the view on screen, which is what makes
     * the `tracklist.*` bindings resolve at all: `data-shortcut-scope`
     * was read by the shortcut service and set by nobody, so Enter and
     * Delete were dead shortcuts the Settings page still advertised. */
    protected override shortcutScope = 'tracklist';

    /**
     * When set, the list displays these tracks instead of
     * fetching all tracks from the library store.  The
     * parent is responsible for reloading when data changes.
     */
    @property({ type: Array, attribute: false })
    externalTracks?: library.Track[];

    /**
     * What a host embedding this list (e.g. `genre-details`) should say
     * a queue built from it came from. Unset when this list is showing
     * the whole library — the one case with no host to ask, and where
     * `effectiveQueueSource` supplies "All Tracks" itself.
     */
    @property({ attribute: false })
    queueSource?: QueueSource;

    /**
     * The library's own top-level Tracks view has no host to name a
     * source — it *is* the source. Anything embedding this list with
     * `externalTracks` is expected to set `queueSource` itself; if it
     * doesn't, the queue is left undescribed rather than mislabeled.
     */
    private get effectiveQueueSource(): QueueSource | undefined {
        if (this.queueSource) return this.queueSource;

        return this.externalTracks
            ? undefined
            : { type: 'tracks', id: 0, label: 'All Tracks' };
    }

    /**
     * Loading, empty and failed are three different things, and this
     * list used to render all three as a permanent “Loading tracks…”
     * — including on the first screen a new user ever sees, behind the
     * first-run wizard (errors.M2, H-12). `home-view` is the model.
     */
    @state() private loadingTracks = false;
    @state() private loadError = '';

    private player = new PlayerController(this);
    private libraryCtrl = new LibraryController(this);
    private searchCtrl = new SearchController(this);
    private trackListCtrl = new TrackListController(this);
    private favCtrl = new FavoritesController(this);
    private selection = new SelectionController(this);
    private ctxMenu = new ContextMenuController(this);
    private lastSearchTerm = '';

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

    /**
     * Resolved column definitions for the currently configured
     * column IDs.  Falls back to defaults for any unknown ID.
     */
    /**
     * The columns the user has chosen — what a desktop draws, and what
     * *anything* may be sorted by.
     *
     * This is deliberately separate from `activeColumns`: "which columns
     * are drawn" and "what can I sort by" are different questions, and
     * the phone is exactly where they diverge. Building the sort list
     * from the drawn columns would silently take sort-by-artist and
     * sort-by-album away from the phone, which has no other route to
     * them since it has no column headers either.
     */
    private get configuredColumns(): ColumnDef[] {
        const ids = this.trackListCtrl.columnIds;
        const chosen = !ids || ids.length === 0 ? DEFAULT_COLUMN_IDS : ids;

        return chosen
            .map((id) => COLUMN_DEFS[id])
            .filter(
                (d): d is ColumnDef =>
                    d !== undefined,
            );
    }

    /** The columns actually drawn: two stacked lines on a phone. */
    private get activeColumns(): ColumnDef[] {
        if (!this.phone) return this.configuredColumns;

        return PHONE_COLUMN_IDS
            .map((id) => COLUMN_DEFS[id])
            .filter(
                (d): d is ColumnDef =>
                    d !== undefined,
            );
    }

    @state()
    private tracks: library.Track[] = [];

    @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;
    }

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

    @query('lit-virtualizer')
    private virtualizer!: LitVirtualizer;

    private lastActiveTrackPath: string | null = null;

    // -- Memoisation caches for filtered / sorted tracks --
    private cachedFilteredTracks: library.Track[] = [];
    private cachedSortedTracks: library.Track[] = [];
    private cachedRelevanceScores = new Map<
        string,
        number
    >();
    private prevFilterTracks: library.Track[] = [];
    private prevFilterTerm = '';
    private prevFilterColIds = '';
    private prevSortFiltered: library.Track[] = [];
    private prevSortField: string | null = null;
    private prevSortDir: SortDirection = 'asc';

    /** The row that holds the list's single tab stop.
     *
     *  A grid of ten thousand rows must not be ten thousand tab stops,
     *  so one row is focusable at a time and the arrows move which — the
     *  standard roving tabindex.  Before this the list had no keyboard
     *  path into it at all (H-5). */
    @state() private focusedIndex = 0;

    private handleSelectAll = (): void => {
        this.selection.selectAll();
    };

    /** Arrow/Home/End move the focused row; Enter plays it.
     *
     *  Enter is not handled here — it is the `tracklist.play` binding,
     *  which resolves because the list claims the `tracklist` shortcut
     *  scope while it is on screen. */
    private onListKeydown = (e: KeyboardEvent): void => {
        const last = this.cachedSortedTracks.length - 1;

        if (last < 0) return;

        let next = this.focusedIndex;

        // The menu is most of what a row can do, and it was reachable
        // only by right-click (a11y.3).
        if (isContextMenuKey(e)) {
            const track = this.cachedSortedTracks[this.focusedIndex];
            const row = e.target instanceof HTMLElement
                ? e.target.closest<HTMLElement>('[role="row"]')
                : null;

            if (!track || !row) return;

            e.preventDefault();
            e.stopPropagation();
            this.selection.handleContextMenu(track.FilePath);
            this.ctxMenu.openFrom(row);

            return;
        }

        switch (e.key) {
            case 'ArrowDown':
                next = Math.min(this.focusedIndex + 1, last);
                break;
            case 'ArrowUp':
                next = Math.max(this.focusedIndex - 1, 0);
                break;
            case 'Home':
                next = 0;
                break;
            case 'End':
                next = last;
                break;
            case ' ':
            case 'Enter':
                return;
            default:
                return;
        }

        e.preventDefault();
        e.stopPropagation();
        this.focusRow(next, { select: e.shiftKey || !e.ctrlKey });
    };

    /** Move the roving tab stop, bringing the row into view and
     *  selecting it so Enter and the context menu have a subject. */
    private focusRow(
        index: number,
        opts: { select?: boolean } = {},
    ): void {
        const track = this.cachedSortedTracks[index];

        if (!track) return;

        this.focusedIndex = index;

        if (opts.select) {
            this.selection.clear();
            this.selection.handleItemClick(
                new MouseEvent('click'),
                track.FilePath,
                index,
            );
        }

        this.virtualizer?.scrollToIndex(index, 'nearest');
        void this.updateComplete.then(() => {
            this.shadowRoot
                ?.querySelector<HTMLElement>(
                    `.track-row[data-index="${index}"]`,
                )
                ?.focus();
        });
    }

    private clearSelectionHandler = (e: MouseEvent) => {
        const path = e.composedPath();
        const isTrackClick = path.some(
            (el) =>
                el instanceof HTMLElement &&
                el.classList.contains('track-row') &&
                this.shadowRoot?.contains(el),
        );

        if (!isTrackClick) {
            this.selection.clear();
        }
    };

    private dragImageEl: HTMLElement | null = null;

    @state()
    private columnWidths: number[] = [];

    /** Column ID to sort by, or null for default order. */
    @state()
    private sortField: string | null = null;

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

    /** Whether delegated event handlers have been attached to the virtualizer. */
    private delegationAttached = false;

    private resizingColumn: number | null = null;
    private resizeStartX = 0;
    private resizeStartWidths: number[] = [];
    private resizeObserver: ResizeObserver | null =
        null;

    // _itemSize matches the fixed .track-row height (33px) so lit-virtualizer
    // doesn't need to measure items. Without this hint, the default 100px
    // estimate causes constant scroll error correction (scrollTo() calls)
    // that produce visible jumping/skipping during scroll.
    /**
     * The virtualizer's item size and the CSS row height are the same
     * number in two places, and they must agree: the layout positions
     * rows from this figure, so a row that is really taller overlaps its
     * neighbour and a shorter one leaves a gap. Both come from here.
     */
    private flowLayout = flow({
        _itemSize: { width: 100, height: ROW_HEIGHT },
    } as Parameters<typeof flow>[0]);

    private phoneFlowLayout = flow({
        _itemSize: { width: 100, height: PHONE_ROW_HEIGHT },
    } as Parameters<typeof flow>[0]);

    private get rowLayout(): Parameters<typeof flow>[0] {
        return this.phone ? this.phoneFlowLayout : this.flowLayout;
    }

    /**
     * Phone width, from the shell's own breakpoint.
     *
     * A media query *inside* a shadow root is answered by the viewport,
     * which is what lets every other component state what it drops at
     * phone width in its own stylesheet. This list cannot: its grid is
     * computed in JS from the host width, so the same threshold has to
     * be readable from JS as well. One breakpoint, two expressions of
     * it, and the reason is written here rather than inferred.
     */
    @state()
    private phone = matchMedia(PHONE_QUERY).matches;

    private phoneQuery = matchMedia(PHONE_QUERY);

    private onPhoneChange = (e: MediaQueryListEvent): void => {
        this.phone = e.matches;
    };
    private hasRestoredScroll = false;
    private scrollSaveRAFId: number | null = null;

    // =================================================================
    // Filtered / sorted tracks (memoised)
    // =================================================================

    /**
     * Recompute the filtered and sorted track caches when
     * their inputs have changed.  Called from willUpdate()
     * so the caches are ready before render().
     */
    private recomputeTrackCaches() {
        const term = this.searchCtrl.term;
        const colIds =
            this.trackListCtrl.columnIds.join(',');

        if (
            this.tracks !== this.prevFilterTracks ||
            term !== this.prevFilterTerm ||
            colIds !== this.prevFilterColIds
        ) {
            this.prevFilterTracks = this.tracks;
            this.prevFilterTerm = term;
            this.prevFilterColIds = colIds;
            this.cachedFilteredTracks =
                this.computeFilteredTracks();
        }

        if (
            this.cachedFilteredTracks !==
                this.prevSortFiltered ||
            this.sortField !== this.prevSortField ||
            this.sortDirection !== this.prevSortDir
        ) {
            this.prevSortFiltered =
                this.cachedFilteredTracks;
            this.prevSortField = this.sortField;
            this.prevSortDir = this.sortDirection;
            this.cachedSortedTracks =
                this.computeSortedTracks();
        }
    }

    private computeFilteredTracks(): library.Track[] {
        const term = this.searchCtrl.term;

        if (!term) {
            this.cachedRelevanceScores.clear();

            return this.tracks;
        }

        const result = rankTracks(
            this.tracks,
            term,
            this.activeColumns,
        );

        this.cachedRelevanceScores = result.scores;

        return result.tracks;
    }

    private computeSortedTracks(): library.Track[] {
        const tracks = this.cachedFilteredTracks;
        const hasSearch =
            this.cachedRelevanceScores.size > 0;
        const col = this.sortField
            ? COLUMN_DEFS[this.sortField]
            : undefined;
        const hasColSort = col?.comparator != null;

        // No search, no column sort — default order.
        if (!hasSearch && !hasColSort) return tracks;

        // No search, column sort only — sort by column.
        if (!hasSearch && hasColSort) {
            const dir =
                this.sortDirection === 'asc' ? 1 : -1;

            return [...tracks].sort(
                (a, b) =>
                    dir * col!.comparator!(a, b),
            );
        }

        // Search active — relevance is primary sort,
        // column sort (if any) is the tiebreaker.
        const scores = this.cachedRelevanceScores;
        const dir =
            this.sortDirection === 'asc' ? 1 : -1;

        return [...tracks].sort((a, b) => {
            const sa = scores.get(a.FilePath) ?? 0;
            const sb = scores.get(b.FilePath) ?? 0;

            if (sa !== sb) return sb - sa;

            if (hasColSort) {
                return dir * col!.comparator!(a, b);
            }

            return 0;
        });
    }

    // =================================================================
    // SelectionHost interface
    // =================================================================

    getItemKey(index: number): string | undefined {
        return this.cachedSortedTracks[index]?.FilePath;
    }

    getItemCount(): number {
        return this.cachedSortedTracks.length;
    }

    onSelectionChanged(): void {
        this.virtualizer?.requestUpdate();
    }

    private get gridTemplateColumns(): string {
        const cols = this.activeColumns;
        const favCol = `${FAV_COL_WIDTH}px`;

        if (this.columnWidths.length === 0 || this.columnWidths.length !== cols.length) {
            return (
                favCol +
                ' ' +
                cols
                    .map((c) => c.defaultWidth)
                    .join(' ')
            );
        }

        return (
            favCol +
            ' ' +
            this.columnWidths
                .map((w) => `${w}px`)
                .join(' ')
        );
    }

    private get colBoundaryPositions(): number[] {
        if (this.columnWidths.length === 0) return [];

        const positions: number[] = [];
        let cumulative =
            ROW_PADDING_X + FAV_COL_WIDTH;

        for (
            let i = 0;
            i < this.columnWidths.length - 1;
            i++
        ) {
            cumulative += this.columnWidths[i] ?? 0;
            positions.push(cumulative);
        }

        return positions;
    }

    private initColumnWidths() {
        // A phone's widths are never the saved ones. `loadColumnWidths`
        // is keyed by column *id* and fills a gap with
        // `MIN_COLUMN_WIDTH`, so the phone's stacked column -- which
        // nothing has ever saved a width for, there being no handles to
        // drag -- came out at the minimum while the duration column
        // inherited a width saved for a four-column desktop row. Found
        // on the device: `24px 148px 236px`, the duration column with
        // 55% of a phone's row.
        if (this.phone) {
            this.computeDefaultWidths();

            return;
        }

        const saved = this.loadColumnWidths();
        const cols = this.activeColumns;

        if (saved && saved.length === cols.length) {
            this.columnWidths = saved;

            return;
        }

        this.computeDefaultWidths();
    }

    /**
     * Width the resizable columns may share.  H-7: this was the raw
     * `clientWidth`, which the columns then summed to exactly — so
     * every row was `ROW_CHROME_WIDTH` (40 px) wider than the box it
     * had to fit in and the last column was always clipped.
     */
    private get availableColumnWidth(): number {
        return this.clientWidth - ROW_CHROME_WIDTH;
    }

    private computeDefaultWidths() {
        const totalWidth = this.availableColumnWidth;

        if (totalWidth <= 0) return;

        const cols = this.activeColumns;

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

        // Fixed-width columns use their pixel default;
        // flex columns share the remainder equally.
        const fixedTotal = cols.reduce((sum, c) => {
            if (c.defaultWidth.endsWith('px')) {
                return (
                    sum +
                    parseInt(c.defaultWidth, 10)
                );
            }

            return sum;
        }, 0);

        const flexCols = cols.filter(
            (c) => !c.defaultWidth.endsWith('px'),
        );

        const remaining = Math.max(
            0,
            totalWidth - fixedTotal,
        );

        const perFlex =
            flexCols.length > 0
                ? Math.floor(
                      remaining / flexCols.length,
                  )
                : DEFAULT_FIXED_WIDTH;

        const raw = cols.map((c) => {
            if (c.defaultWidth.endsWith('px')) {
                return parseInt(c.defaultWidth, 10);
            }

            return Math.max(
                MIN_COLUMN_WIDTH,
                perFlex,
            );
        });

        this.columnWidths = this.normalizeWidths(raw);
    }

    private loadColumnWidths(): number[] | null {
        try {
            const raw = localStorage.getItem(
                COLUMN_STORAGE_KEY,
            );

            if (!raw) return null;

            const parsed: unknown = JSON.parse(raw);

            // Support new id-keyed format: Record<string, number>.
            if (
                parsed !== null &&
                typeof parsed === 'object' &&
                !Array.isArray(parsed)
            ) {
                const map = parsed as Record<
                    string,
                    unknown
                >;
                const cols = this.activeColumns;

                const widths = cols.map((c) => {
                    const w = map[c.id];

                    if (
                        typeof w === 'number' &&
                        w >= MIN_COLUMN_WIDTH
                    ) {
                        return w;
                    }

                    // Fallback for columns without saved width.
                    if (
                        c.defaultWidth.endsWith('px')
                    ) {
                        return parseInt(
                            c.defaultWidth,
                            10,
                        );
                    }

                    return MIN_COLUMN_WIDTH;
                });

                return this.normalizeWidths(widths);
            }

            // Legacy array format — discard on column count mismatch.
            return null;
        } catch {
            return null;
        }
    }

    private saveColumnWidths() {
        // And a phone's widths are never *saved*: they are computed from
        // a column set the user did not choose, and writing them would
        // overwrite the width they dragged for the same column on a
        // desktop. Nothing on a phone can resize a column anyway, so
        // this is only reachable by a window crossing the breakpoint.
        if (this.phone) return;

        try {
            const cols = this.activeColumns;

            const map: Record<string, number> = {};

            cols.forEach((c, i) => {
                map[c.id] =
                    this.columnWidths[i] ??
                    MIN_COLUMN_WIDTH;
            });

            localStorage.setItem(
                COLUMN_STORAGE_KEY,
                JSON.stringify(map),
            );
        } catch {
            // Ignore storage errors.
        }
    }

    /**
     * Scale widths so they sum to exactly the width available to the
     * columns (the host minus the row chrome).
     * Every column is guaranteed at least MIN_COLUMN_WIDTH.
     */
    private normalizeWidths(
        widths: number[],
    ): number[] {
        const container = this.availableColumnWidth;

        if (container <= 0 || widths.length === 0) {
            return widths;
        }

        const minTotal =
            widths.length * MIN_COLUMN_WIDTH;

        // If the container can't even fit minimums,
        // give every column the minimum.
        if (container <= minTotal) {
            return widths.map(
                () => MIN_COLUMN_WIDTH,
            );
        }

        const sum = widths.reduce(
            (a, b) => a + b,
            0,
        );

        if (sum <= 0) {
            const even = Math.floor(
                container / widths.length,
            );

            return widths.map(() =>
                Math.max(MIN_COLUMN_WIDTH, even),
            );
        }

        // Scale proportionally.
        const scale = container / sum;

        const scaled = widths.map((w) =>
            Math.max(
                MIN_COLUMN_WIDTH,
                Math.round(w * scale),
            ),
        );

        // Fix rounding remainder so the total is
        // exactly containerWidth.
        const scaledSum = scaled.reduce(
            (a, b) => a + b,
            0,
        );

        const diff = container - scaledSum;

        if (diff !== 0) {
            // Apply remainder to the widest column.
            let maxIdx = 0;

            for (let i = 1; i < scaled.length; i++) {
                if (
                    (scaled[i] ?? 0) >
                    (scaled[maxIdx] ?? 0)
                ) {
                    maxIdx = i;
                }
            }

            scaled[maxIdx] =
                (scaled[maxIdx] ?? 0) + diff;
        }

        return scaled;
    }

    /** perf.m4: the drag's document listeners exist while it is dragging
     *  and not before, which is the standard pattern and stops every
     *  pointer move in the app calling a handler that guards and
     *  returns. */
    private attachColResizeListeners(on: boolean): void {
        const fn = on ? 'addEventListener' : 'removeEventListener';

        document[fn]('mousemove', this.onColResizeMove as EventListener);
        document[fn]('mouseup', this.onColResizeEnd as EventListener);
    }

    private onColResizeStart = (e: MouseEvent, columnIndex: number) => {
        e.preventDefault();
        this.resizingColumn = columnIndex;
        this.resizeStartX = e.clientX;
        this.resizeStartWidths = [...this.columnWidths];
        this.attachColResizeListeners(true);
        this.requestUpdate();
    };

    private onColResizeMove = (e: MouseEvent) => {
        if (this.resizingColumn === null) return;

        const container = this.availableColumnWidth;

        if (container <= 0) return;

        const delta = e.clientX - this.resizeStartX;
        const col = this.resizingColumn;
        const starts = this.resizeStartWidths;

        // Sum of columns to the left (unchanged).
        let leftSum = 0;

        for (let i = 0; i < col; i++) {
            leftSum += starts[i] ?? 0;
        }

        // Count and sum of columns to the right.
        const rightCount =
            starts.length - col - 1;

        let rightSum = 0;

        for (
            let i = col + 1;
            i < starts.length;
            i++
        ) {
            rightSum += starts[i] ?? 0;
        }

        // Clamp dragged column: leave at least
        // MIN_COLUMN_WIDTH for each right column.
        const maxWidth =
            container -
            leftSum -
            rightCount * MIN_COLUMN_WIDTH;

        let newWidth = Math.max(
            MIN_COLUMN_WIDTH,
            Math.min(
                maxWidth,
                (starts[col] ?? 0) + delta,
            ),
        );

        const updated: number[] = new Array(
            starts.length,
        );

        // Left columns keep starting widths.
        for (let i = 0; i < col; i++) {
            updated[i] = starts[i] ?? 0;
        }

        updated[col] = newWidth;

        // Right columns always fill remaining space
        // proportionally (handles both grow & shrink).
        const availableForRight =
            container - leftSum - newWidth;

        if (rightCount === 0 || rightSum <= 0) {
            // Nothing to distribute.
        } else {
            const scale =
                availableForRight / rightSum;

            let roundedSum = 0;
            let maxIdx = -1;
            let maxVal = 0;

            for (
                let i = col + 1;
                i < starts.length;
                i++
            ) {
                const scaled = Math.max(
                    MIN_COLUMN_WIDTH,
                    Math.round(
                        (starts[i] ?? 0) * scale,
                    ),
                );

                updated[i] = scaled;
                roundedSum += scaled;

                if (scaled > maxVal) {
                    maxVal = scaled;
                    maxIdx = i;
                }
            }

            // Fix rounding remainder on the widest
            // right column.
            const diff =
                availableForRight - roundedSum;

            if (diff !== 0 && maxIdx >= 0) {
                updated[maxIdx] =
                    (updated[maxIdx] ?? 0) + diff;
            }

            // Re-derive dragged width so total is
            // exactly container.
            newWidth =
                container -
                leftSum -
                roundedSum -
                diff;

            if (newWidth < MIN_COLUMN_WIDTH) {
                newWidth = MIN_COLUMN_WIDTH;
            }

            updated[col] = newWidth;
        }

        this.columnWidths = updated;
    };

    private onColResizeEnd = () => {
        this.attachColResizeListeners(false);

        if (this.resizingColumn === null) return;

        this.resizingColumn = null;
        this.saveColumnWidths();
        this.requestUpdate();
    };

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

    .table-container {
      position: relative;
      flex: 1;
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }

    /* ---- Header row ---- */

    .header-row {
      display: grid;
      grid-template-columns: var(--grid-cols);
      padding: 8px;
      font-weight: bold;
      color: var(--yj-text-primary, #fff);
      border-bottom: 1px solid
        var(--yj-text-tertiary, #666);
      flex-shrink: 0;
      overflow: hidden;
    }

    .header-cell {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 4px;
    }

    .header-cell:hover {
      color: var(--yj-accent-text, #ffd43b);
    }

    .sort-arrow {
      /* a11y.34. Was 10px, below the type scale's own floor, with a
         comment acknowledging it. The finding's stated harm — "the sort
         direction is a 10px glyph or nothing" — is half closed already:
         Phase 1 gave these cells an aria-sort, so it is announced. What
         is left is a sighted user reading it, and nothing in the header
         needs it to be smaller than the smallest text in the app. */
      font-size: var(--yj-text-xs, 11px);
      flex-shrink: 0;
      color: var(--yj-accent-text, #ffd43b);
    }

    .resize-overlay {
      position: absolute;
      inset: 0;
      pointer-events: none;
      z-index: 2;
    }

    .col-resize-handle {
      position: absolute;
      top: 0;
      height: 100%;
      width: 1px;
      cursor: col-resize;
      pointer-events: auto;
      background-color: var(--yj-border, #444);
      transition: background-color 0.15s ease;
    }

    .col-resize-handle::before {
      content: '';
      position: absolute;
      top: 0;
      left: -3px;
      width: 7px;
      height: 100%;
    }

    .col-resize-handle:hover,
    .col-resize-handle.active {
      background-color: var(--yj-text-tertiary, #6c757d);
    }

    .no-results {
      padding: 24px 16px;
      color: var(--yj-text-secondary, #b3b3b3);
      font-size: var(--yj-text-md);
    }

    lit-virtualizer {
      flex: 1;
      overflow-x: hidden;
      overflow-y: auto;
      user-select: none;
      contain: paint;
      overflow-anchor: none;
    }

    .track-row {
      position: relative;
      display: grid;
      grid-template-columns: var(--grid-cols);
      font-size: var(--yj-text-sm);
      padding: 8px;
      border-bottom: 1px solid var(--yj-border-subtle, #333);
      align-items: center;
      width: 100%;
      cursor: default;
      user-select: none;
      overflow: hidden;
      height: 33px;
      box-sizing: border-box;
      contain: strict;
    }

    /* A phone row is two lines, and this height must equal
       PHONE_ROW_HEIGHT: the virtualizer positions rows from that number,
       so a taller row overlaps its neighbour and a shorter one gaps. */
    @media (max-width: 599px) {
      .track-row {
        height: 52px;
      }
    }

    .stacked {
      display: flex;
      flex-direction: column;
      justify-content: center;
      gap: 2px;
      min-width: 0;
    }

    .stacked-title,
    .stacked-sub {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .stacked-sub {
      font-size: var(--yj-text-xs);
      color: var(--yj-text-secondary, #b3b3b3);
    }

    .track-row > * {
      min-width: 0;
    }

    .header-row > :not(:first-child),
    .track-row > :not(:first-child) {
      padding-left: 6px;
    }

    .track-row:hover {
      background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
    }

    .track-row.selected {
      background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
    }

    .track-row.active {
      background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
    }

    .track-row.active {
      color: var(--yj-accent-text, #ffd43b);
    }

    /* a11y.22: the playing row was a background tint and a text colour
       and nothing else, so a colour-blind user could not find it
       (WCAG 1.4.1). A triangle drawn in the row's own 8px left padding
       is a *shape* that is present or absent, and it costs no layout:
       the grid columns are computed from the host width and every one
       of them would have had to move for a marker in the flow. Sized
       to that padding rather than to the glyph a font would give. */
    .track-row.active::before {
      content: '';
      position: absolute;
      left: 1px;
      top: 50%;
      transform: translateY(-50%);
      border-left: 5px solid currentColor;
      border-top: 4px solid transparent;
      border-bottom: 4px solid transparent;
    }

    .track-row.selected.active {
      background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
    }

    .cell {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      cursor: default;
      user-select: none;
    }

    .cell-right {
      text-align: right;
    }

    .cell-center {
      text-align: center;
    }

    .fav-icon {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 24px;
      flex-shrink: 0;
      cursor: pointer;
      color: var(--yj-text-tertiary, #666);
      font-size: var(--yj-text-sm);
    }

    .fav-icon:hover {
      color: var(--yj-text-primary, #fff);
    }

    .fav-icon.favorited {
      color: var(--yj-accent-text, #ffd43b);
    }

    .fav-icon.favorited:hover {
      color: var(--yj-accent-text, #ffd43b);
      opacity: 0.8;
    }

    .search-match {
      background-color: rgba(255, 212, 59, 0.15);
      border-radius: 2px;
    }

    .list-placeholder {
      color: var(--yj-text-secondary, #b3b3b3);
      padding: 1em;
    }

    .placeholder-action {
      background: none;
      border: 1px solid var(--yj-border, #495057);
      border-radius: 4px;
      color: inherit;
      cursor: pointer;
      font: inherit;
      margin-top: 0.5em;
      padding: 4px 10px;
    }

  `];

    override connectedCallback() {
        super.connectedCallback();
        this.restoreSortPreferences();

        if (this.externalTracks) {
            this.tracks = this.externalTracks;
        } else {
            this.loadTracks();
        }
        this.resizeObserver = new ResizeObserver(
            () => {
                this.onHostResize();
            },
        );

        this.resizeObserver.observe(this);

        // Connection, not the view lifecycle: this only sets state, so
        // it is harmless (and wanted) while the list is off screen -- a
        // rotation on another view must not leave this one laid out for
        // the wrong width when the user comes back to it.
        this.phoneQuery.addEventListener('change', this.onPhoneChange);
    }

    override disconnectedCallback() {
        this.phoneQuery.removeEventListener('change', this.onPhoneChange);

        // Remove delegated event handlers from virtualizer.
        const virt = this.virtualizer;
        if (virt) {
            virt.removeEventListener('click', this.onDelegatedClick);
            virt.removeEventListener('dblclick', this.onDelegatedDblClick);
            virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
            virt.removeEventListener('dragstart', this.onDelegatedDragStart);
            virt.removeEventListener('dragend', this.onTrackDragEnd);
        }
        this.delegationAttached = false;

        this.virtualizer?.removeEventListener(
            'visibilityChanged',
            this.onVisibilityChanged,
        );
        this.hasRestoredScroll = false;
        // A drag interrupted by the list going away still has to clean
        // up after itself; these are no longer registered with the view
        // lifecycle, so nothing else would.
        this.attachColResizeListeners(false);
        super.disconnectedCallback();

        this.resizeObserver?.disconnect();
        this.resizeObserver = null;
    }

    /** Document-level listeners belong to the *visible* list.  A cached
     *  list is never disconnected, so this is the only place they can be
     *  taken down again. */
    protected override onViewActivate(): void {
        this.listenWhileActive(document, 'click', this.clearSelectionHandler);
        this.listenWhileActive(
            document,
            'shortcut:select-all',
            this.handleSelectAll,
        );
        this.listenWhileActive(
            document,
            'shortcut:tracklist-play',
            this.handleShortcutPlay,
        );
        this.listenWhileActive(
            document,
            'shortcut:tracklist-delete',
            this.handleShortcutDelete,
        );

        // Credits arrive after the rows that asked for them.  The
        // virtualizer produces its rows from its *own* properties, so a
        // host re-render alone repaints nothing — the same reason a
        // selection change pushes requestUpdate() into it.
        this.whileActive(
            creditStore.subscribe(() => {
                this.requestUpdate();
                this.virtualizer?.requestUpdate();
            }),
        );
    }

    /**
     * Delete opens the confirmation and does nothing else.
     *
     * That is the whole design of the binding: one keystroke from a
     * focused row, a key that *asks* is defensible and a key that
     * *acts* is not — so this is the same dialog the menu command
     * opens, reached by a different route.
     */
    private handleShortcutDelete = (): void => {
        const filePaths = this.selection.getSelectedKeysOrdered();

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

        void this.removeFromLibrary(filePaths);
    };

    /** Enter plays the selection — the `tracklist.play` binding, which
     *  has existed in the defaults and in Settings since it was written
     *  and has never had anything on the other end of it. */
    private handleShortcutPlay = (): void => {
        this.playSelection(this.selection.getSelectedKeysOrdered());
    };

    /**
     * "Play" means the same thing from the menu and from Enter, and it
     * asks how much the user selected. One row is a position in the
     * list - it queues the list from there, exactly as double-clicking
     * does. Several rows are an explicit choice of *those* tracks, so
     * they become the queue on their own (and `shuffleStart` applies,
     * since no one row was named as the place to start).
     */
    private playSelection(filePaths: string[]): void {
        if (filePaths.length === 0) return;

        if (filePaths.length === 1) {
            const index = this.displayIndexOf(filePaths[0]!);

            if (index >= 0) {
                this.playFromRow(index);

                return;
            }
        }

        queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
    }

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

        // When the parent provides a new external track
        // list, update local tracks and reset selection.
        if (
            changed.has('externalTracks') &&
            this.externalTracks
        ) {
            this.tracks = this.externalTracks;
            this.selection.clear();
        }

        this.recomputeTrackCaches();
    }

    override firstUpdated() {
        this.initColumnWidths();
        this.attachDelegation();
    }

    /**
     * Attach delegated event handlers to the virtualizer.
     * The virtualizer may not exist on first render (tracks
     * still loading), so this is called from both
     * firstUpdated() and updated() — guarded by a flag.
     */
    private attachDelegation() {
        if (this.delegationAttached) return;

        const virt = this.virtualizer;

        if (!virt) return;

        virt.addEventListener('click', this.onDelegatedClick);
        virt.addEventListener('dblclick', this.onDelegatedDblClick);
        virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
        virt.addEventListener('dragstart', this.onDelegatedDragStart);
        virt.addEventListener('dragend', this.onTrackDragEnd);
        this.delegationAttached = true;
    }

    override updated(changed: Map<string, unknown>) {
        // The virtualizer may not exist on first render
        // (tracks still loading). Retry delegation here.
        this.attachDelegation();

        // Recompute widths when the column config changes.
        const colKey = this.trackListCtrl.columnIds.join(
            ',',
        );

        if (colKey !== this.previousColumnIds) {
            this.previousColumnIds = colKey;
            this.initColumnWidths();
        }

        if (changed.has('columnWidths')) {
            this.style.setProperty(
                '--grid-cols',
                this.gridTemplateColumns,
            );
        }

        const currentPath =
            this.player.currentTrack?.filePath ?? null;

        if (currentPath !== this.lastActiveTrackPath) {
            this.lastActiveTrackPath = currentPath;
            this.virtualizer?.requestUpdate();
        }

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

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

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

            if (
                cached !== null &&
                cached !== this.lastTracksRef
            ) {
                this.lastTracksRef = cached;
                this.loadTracks();
            }
        }
    }

    private previousHostWidth = 0;
    private previousColumnIds = '';

    private onHostResize() {
        const newWidth = this.clientWidth;

        if (
            newWidth <= 0 ||
            this.columnWidths.length === 0 ||
            this.resizingColumn !== null
        ) {
            return;
        }

        if (this.previousHostWidth === 0) {
            this.previousHostWidth = newWidth;

            return;
        }

        this.columnWidths = this.normalizeWidths(
            this.columnWidths,
        );

        this.previousHostWidth = newWidth;
        this.saveColumnWidths();
    }

    async loadTracks() {
        this.loadingTracks = this.tracks.length === 0;
        this.loadError = '';

        try {
            const tracks = await this.libraryCtrl.getTracks();
            this.tracks = tracks;

            // Keep what is still there. A refetch is not a
            // deselection: this used to clear, so every finished track
            // wiped the user's selection while music played (perf.C2).
            // The keys are file paths, which survive a refetch.
            const present = new Set(tracks.map((t) => t.FilePath));
            this.selection.retain((key) => present.has(key));

            await this.updateComplete;

            if (this.isConnected && this.virtualizer) {
                this.virtualizer.addEventListener(
                    'visibilityChanged',
                    this.onVisibilityChanged,
                );
            }
        } catch (error) {
            console.error('Error loading tracks:', error);
            this.loadError = describeError(
                error,
                'Your tracks could not be loaded.',
            );
        } finally {
            this.loadingTracks = false;
        }
    }

    /**
     * What a screen reader is told about this list, in a sentence.
     *
     * Loading, failed, empty and "n results for a search" were all
     * silent — the list said them in text nobody was watching (a11y.12).
     */
    private liveStatus(visible: number): string {
        if (this.loadError) return this.loadError;

        if (this.loadingTracks) return 'Loading tracks…';

        if (this.tracks.length === 0) return 'No tracks.';

        const term = this.searchCtrl.term.trim();

        if (term === '') return '';

        return visible === 0
            ? `No tracks match “${term}”.`
            : `${visible} track${visible === 1 ? '' : 's'} match “${term}”.`;
    }

    /** Loading / failed / genuinely empty, said apart. */
    private renderPlaceholder() {
        if (this.loadError) {
            return html`
                <div class="list-placeholder" data-testid="track-list-error">
                    <p>${this.loadError}</p>
                    <button
                        type="button"
                        class="placeholder-action"
                        @click=${() => void this.loadTracks()}
                    >
                        Try again
                    </button>
                </div>
            `;
        }

        if (this.loadingTracks) {
            return html`<p class="list-placeholder" data-testid="track-list-loading">
                Loading tracks…
            </p>`;
        }

        return html`<p class="list-placeholder" data-testid="track-list-empty">
            ${this.externalTracks
                ? 'Nothing here yet.'
                : 'No tracks yet — add a folder in Settings to get started.'}
        </p>`;
    }

    private onVisibilityChanged = (e: Event) => {
        const { first } = e as VisibilityChangedEvent;

        if (!this.hasRestoredScroll) {
            this.hasRestoredScroll = true;

            const savedIndex =
                this.libraryCtrl.getScrollPosition('tracks');

            if (savedIndex > 0) {
                requestAnimationFrame(() => {
                    this.virtualizer?.scrollToIndex(
                        savedIndex,
                        'start',
                    );
                });

                return;
            }
        }

        // RAF-throttle scroll position saves — at most once per frame.
        // Without this, every visibilityChanged (fired per-item during
        // scroll) writes to the store synchronously, adding main-thread
        // work during the scroll hot path.
        if (this.scrollSaveRAFId === null) {
            this.scrollSaveRAFId = requestAnimationFrame(() => {
                this.scrollSaveRAFId = null;
                this.libraryCtrl.setScrollPosition('tracks', first);
            });
        }
    };

    // =================================================================
    // Delegated event handlers (stable references, zero per-item closures)
    // =================================================================

    /**
     * Walk up from the event target to find the nearest
     * `.track-row` and extract track + index via `data-index`.
     */
    private resolveTrackFromEvent(
        e: Event,
    ): { track: library.Track; index: number } | null {
        const row = (e.target as HTMLElement).closest(
            '.track-row',
        ) as HTMLElement | null;

        if (!row) return null;

        const idx = Number(row.dataset.index);
        const track = this.cachedSortedTracks[idx];

        if (!track) return null;

        return { track, index: idx };
    }

    private onDelegatedClick = (e: MouseEvent) => {
        const hit = this.resolveTrackFromEvent(e);

        if (!hit) return;

        // Check if click was on fav-icon
        const favEl = (e.target as HTMLElement).closest(
            '.fav-icon',
        );

        if (favEl) {
            e.stopPropagation();
            void this.favCtrl.toggleFavorite(
                hit.track.FilePath,
            );

            return;
        }

        this.onTrackRowClick(e, hit.track, hit.index);
    };

    private onDelegatedDblClick = (e: MouseEvent) => {
        const hit = this.resolveTrackFromEvent(e);

        if (hit) this.onTrackRowDblClick(hit.track, hit.index);
    };

    private onDelegatedContextMenu = (e: MouseEvent) => {
        const hit = this.resolveTrackFromEvent(e);

        if (hit) this.onTrackContextMenu(e, hit.track);
    };

    private onDelegatedDragStart = (e: DragEvent) => {
        const hit = this.resolveTrackFromEvent(e);

        if (hit) this.onTrackDragStart(e, hit.track);
    };

    // =================================================================
    // Original handlers (still used internally)
    // =================================================================

    private onTrackRowClick(
        e: MouseEvent,
        track: library.Track,
        index: number,
    ) {
        // Clicking is also how the keyboard's starting point is chosen:
        // tabbing back into the list should land where the user was.
        this.focusedIndex = index;
        this.selection.handleItemClick(e, track.FilePath, index);
    }

    private onTrackRowDblClick(_track: library.Track, index: number) {
        this.selection.clear();
        this.playFromRow(index);
    }

    /**
     * Activating one row plays the list that row is in, from that row -
     * the library, the artist or the genre the user is looking at, not
     * a queue of one. The paths come from `cachedSortedTracks`, so it
     * is the list as *displayed*: whatever the current sort, search and
     * library filter have made of it, which is the only order the user
     * can see and therefore the only one they can mean.
     */
    private playFromRow(index: number) {
        const filePaths = this.cachedSortedTracks.map(
            (t) => t.FilePath,
        );

        if (filePaths.length === 0 || index < 0) return;

        queueStore.setQueue(
            filePaths,
            index,
            false,
            this.effectiveQueueSource,
        );
    }

    /**
     * Where a "play this" command lands in the displayed list, or -1.
     *
     * Selection keys are file paths, which survive the re-sorts and
     * refetches an index does not - so the index is looked up at the
     * moment it is used rather than remembered.
     */
    private displayIndexOf(filePath: string): number {
        return this.cachedSortedTracks.findIndex(
            (t) => t.FilePath === filePath,
        );
    }

    private onTrackContextMenu(e: MouseEvent, track: library.Track) {
        e.preventDefault();
        e.stopPropagation();

        this.selection.handleContextMenu(track.FilePath);
        this.ctxMenu.openAt(e.clientX, e.clientY);
    }

    // =================================================================
    // Drag source
    // =================================================================

    private onTrackDragStart = (
        e: DragEvent,
        track: library.Track,
    ) => {
        // Gather file paths: all selected if this track is selected,
        // otherwise just the dragged track.
        let filePaths: string[];

        if (this.selection.isSelected(track.FilePath)) {
            filePaths =
                this.selection.getSelectedKeysOrdered();
        } else {
            filePaths = [track.FilePath];
        }

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

        setDragPayload(e, {
            filePaths,
            source: 'track-list',
        });

        // Custom drag image.
        this.dragImageEl =
            filePaths.length === 1
                ? createTrackCardDragImage(
                      track.TrackName,
                      track.ArtistName,
                      track.FilePath,
                  )
                : createDragImage(filePaths.length);
        e.dataTransfer?.setDragImage(
            this.dragImageEl,
            0,
            0,
        );

        emitDragActive(true);
    };

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

        emitDragActive(false);
    };

    private onContextMenuAction(action: string) {
        const filePaths =
            this.selection.getSelectedKeysOrdered();

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

        switch (action) {
            case 'play':
                this.playSelection(filePaths);
                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;
            case 'remove-from-library':
                // The only destructive command in this menu: it asks
                // first, and it keeps the selection until the user has
                // answered — the dialog names a count, and clearing the
                // selection under it would make that count a claim
                // about nothing.
                this.ctxMenu.close();
                void this.removeFromLibrary(filePaths);

                return;
        }

        this.selection.clear();
        this.ctxMenu.close();
    }

    /**
     * "Remove from library", behind a confirmation that says what it
     * does *and* what it does not.
     *
     * The second half is the point. This deletes the database rows and
     * stops the scanner importing those paths again; the audio files
     * are left exactly where they are. A user who reads "remove" as
     * "delete" and finds their music gone would have been failed by the
     * copy, not by the operation — so the copy says so in the impact
     * line, where the consequence of every other destructive action in
     * the app is written.
     */
    private async removeFromLibrary(filePaths: string[]) {
        const count = filePaths.length;
        const only =
            count === 1
                ? tracksByFilePath(this.tracks).get(filePaths[0]!)
                : undefined;

        const ok = await confirmAction({
            title:
                count === 1
                    ? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
                    : `Remove ${count.toLocaleString()} tracks from the library?`,
            message:
                count === 1
                    ? 'It is removed from YellowJacket and will not be added' +
                      ' back by a future scan.'
                    : 'They are removed from YellowJacket and will not be' +
                      ' added back by a future scan.',
            impact:
                count === 1
                    ? 'The file is not deleted — it stays on disk exactly' +
                      ' where it is. A full rescan brings it back.'
                    : 'The files are not deleted — they stay on disk exactly' +
                      ' where they are. A full rescan brings them back.',
            confirmLabel:
                count === 1
                    ? 'Remove track'
                    : `Remove ${count.toLocaleString()} tracks`,
            danger: true,
        });

        if (!ok) return;

        try {
            await RemoveFromLibrary(filePaths);
            this.selection.clear();
        } catch (error) {
            console.error('Error removing tracks from library:', error);
            notificationStore.persistent({
                title: 'Could not remove from library',
                text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
            });
        }
    }

    private onContextMenuFavoriteToggle() {
        const filePaths =
            this.selection.getSelectedKeysOrdered();

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

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

        this.selection.clear();
        this.ctxMenu.close();
    }

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

        if (!track) return;

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

        if (!ready) return;

        const coverArt = track.CoverArtPath
            ? {
                coverArtPath: track.CoverArtPath,
                coverArtSmall: track.CoverArtSmall,
                coverArtMedium: track.CoverArtMedium,
                coverArtLarge: track.CoverArtLarge,
            }
            : undefined;

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

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

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

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

        if (!ready) return;

        // Use cover art from the first track. If all tracks share
        // the same album, they share the same art.
        const first = tracks[0]!;
        let coverArt: CoverArtUrls | null = null;
        let coverArtMixed = false;

        const albumNames = new Set(tracks.map((t) => t.Album));

        if (albumNames.size === 1 && first.CoverArtPath) {
            coverArt = {
                coverArtPath: first.CoverArtPath,
                coverArtSmall: first.CoverArtSmall,
                coverArtMedium: first.CoverArtMedium,
                coverArtLarge: first.CoverArtLarge,
            };
        } else if (albumNames.size > 1) {
            coverArtMixed = true;
        }

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

    // =================================================================
    // 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 &&
                COLUMN_DEFS[field]?.comparator
            ) {
                this.sortField = field;
            }

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

    /** Persist sort preferences to localStorage. */
    private saveSortPreferences() {
        try {
            if (this.sortField) {
                localStorage.setItem(
                    SORT_FIELD_KEY,
                    this.sortField,
                );
            } else {
                localStorage.removeItem(
                    SORT_FIELD_KEY,
                );
            }

            localStorage.setItem(
                SORT_DIR_KEY,
                this.sortDirection,
            );
        } catch {
            // Ignore storage errors.
        }
    }

    /**
     * Handle a click on a column header to toggle sorting.
     * First click: sort ascending. Second: descending.
     * Third: clear sort (back to default order).
     */
    private onHeaderCellClick(colId: string) {
        const col = COLUMN_DEFS[colId];

        if (!col?.comparator) return;

        if (this.sortField === colId) {
            if (this.sortDirection === 'asc') {
                this.sortDirection = 'desc';
            } else {
                this.sortField = null;
                this.sortDirection = 'asc';
            }
        } else {
            this.sortField = colId;
            this.sortDirection = 'asc';
        }

        this.saveSortPreferences();
    }

    private isActiveTrack(track: library.Track): boolean {
        const currentTrack = this.player.currentTrack;

        if (!currentTrack) return false;

        return currentTrack.filePath === track.FilePath;
    }

    private renderTrackRow = (
        track: library.Track,
        index: number,
    ): unknown => {
        const active = this.isActiveTrack(track);
        const selected = this.selection.isSelected(
            track.FilePath,
        );

        const cols = this.activeColumns;

        const isFav = this.favCtrl.isFavorited(
            track.FilePath,
        );

        // Inline SVG instead of wa-icon — eliminates a shadow DOM tree
        // per visible row (~30-50 during scroll).
        const iconDef = FAV_ICONS[this.favCtrl.iconStyle === 'star' ? 'star' : 'heart'];
        const iconPath = isFav ? iconDef.solid : iconDef.regular;

        // No inline closures — all events delegated via data-index
        // on the virtualizer element (see firstUpdated).
        const term = this.searchCtrl.term;
        return html`
      <div
        class=${classMap({
            'track-row': true,
            active,
            selected,
        })}
        role="row"
        aria-rowindex=${index + 1}
        aria-selected=${selected}
        aria-current=${active ? 'true' : 'false'}
        tabindex=${index === this.focusedIndex ? 0 : -1}
        draggable="true"
        data-index=${index}
        data-testid="track-row"
        data-file-path=${track.FilePath}
      >
        <div
          role="gridcell"
          class=${classMap({
            'fav-icon': true,
            favorited: isFav,
        })}
        >
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${iconDef.viewBox.split(' ').slice(2).join(' ')}" width="14" height="14">
            ${svg`<path fill="currentColor" d="${iconPath}"/>`}
          </svg>
        </div>
        ${cols.map((col) => {
                const customCell = col.renderCell?.(track, term);
                if (customCell !== undefined && customCell !== nothing) {
                    return html`<div role="gridcell" class="cell">${customCell}</div>`;
                }
                const val = col.accessor(track);
                const centered = val === '\u2014';
                let display: unknown = term
                    ? highlightText(val, term)
                    : val;

                // Wrap artist/album/track values in explore links.
                if (col.id === 'trackName') {
                    display = trackLink(track.TrackName, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, display as any, track.ArtistName);
                } else if (col.id === 'artistName') {
                    // A search term highlights the *flat* credit string,
                    // and mapping those spans onto decomposed parts is a
                    // different problem from rendering the credit.  While
                    // filtering, the single link is the honest answer.
                    creditStore.request(track.RecordingMBID);
                    const parts = term ? undefined : creditStore.get(track.RecordingMBID);
                    display = parts && parts.length > 1
                        ? creditLink(parts, track.ArtistName, track.ArtistMBID)
                        : artistLink(track.ArtistName, track.ArtistMBID, display as any);
                } else if (col.id === 'album') {
                    display = albumLink(track.Album, track.ReleaseGroupMBID, display as any, track.ArtistName);
                }

                // `title` on the cell rather than on whatever is inside
                // it: the value may be a link, a highlighted match or
                // plain text, and a tooltip is inherited by descendants
                // either way (a11y.24).
                return html`
                    <div role="gridcell" title=${val} class=${classMap({
                        cell: true,
                        'cell-center': centered,
                        'cell-right': !centered && col.align === 'right',
                    })}>
                        ${display}
                    </div>
                `;
            })}
      </div>
    `;
    };

    /**
     * The page header, which carries what used to be a hand-rolled
     * sort toolbar written out twice (here and in `cover-grid`).
     *
     * `externalTracks` means this list is a section of some other page
     * — the genre and playlist details — which has a heading already,
     * so the header keeps the count and the sort and drops the title.
     */
    private renderPageHeader() {
        const options: SortOption[] = [
            { id: '', label: 'Default' },
            ...this.configuredColumns
                .filter((c) => c.comparator)
                .map((c) => ({ id: c.id, label: c.label })),
        ];

        return html`
            <page-header
                heading=${this.externalTracks === undefined ? 'Tracks' : ''}
                .count=${this.loadingTracks
                    ? null
                    : this.cachedSortedTracks.length}
                count-noun="track"
                .sortOptions=${options}
                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: 'asc' | 'desc' }>,
    ) => {
        this.sortField =
            e.detail.field === '' ? null : e.detail.field;
        this.sortDirection = e.detail.direction;
        this.saveSortPreferences();
    };

    override render() {
        const visibleTracks = this.cachedSortedTracks;
        const cols = this.activeColumns;

        return html`
      ${this.renderPageHeader()}
      <div class="sr-only" role="status" aria-live="polite">
        ${this.liveStatus(visibleTracks.length)}
      </div>
      ${this.tracks.length === 0
                ? this.renderPlaceholder()
                : html`
            <div
              class="table-container"
              role="grid"
              aria-label="Tracks"
              aria-rowcount=${visibleTracks.length}
              aria-busy=${this.loadingTracks}
              @keydown=${this.onListKeydown}
            >
            ${this.phone ? nothing : html`<div class="header-row" role="row">
              <div role="columnheader" aria-label="Favourite"></div>
              ${cols.map(
                    (col) => html`
                <div
                  role="columnheader"
                  tabindex="0"
                  aria-sort=${this.sortField === col.id
                          ? (this.sortDirection === 'asc' ? 'ascending' : 'descending')
                          : 'none'}
                  class="header-cell ${col.align === 'right' ? 'cell-right' : ''}"
                  @click=${() =>
                          this.onHeaderCellClick(
                              col.id,
                          )}
                  @keydown=${(e: KeyboardEvent) => {
                          if (e.key !== 'Enter' && e.key !== ' ') return;

                          e.preventDefault();
                          e.stopPropagation();
                          this.onHeaderCellClick(col.id);
                      }}
                >
                  <span>${col.label}</span>
                  ${this.sortField === col.id
                              ? html`<span
                            class="sort-arrow"
                        >
                            ${this.sortDirection === 'asc' ? '\u25B2' : '\u25BC'}
                        </span>`
                              : nothing}
                </div>
              `,
                )}
            </div>`}
            ${visibleTracks.length === 0
                        ? html`<p class="no-results">
                      No tracks match your search.
                    </p>`
                        : html`
                      <lit-virtualizer
                        scroller
                        .items=${visibleTracks}
                        .renderItem=${this.renderTrackRow}
                        .keyFunction=${(track: library.Track) => track.FilePath}
                        .layout=${this.rowLayout}
                      ></lit-virtualizer>
                    `}

      <!-- Resizing is a pointer gesture with no touch equivalent, and
           the phone's two columns are not the user's to arrange. -->
      <div class="resize-overlay">
        ${(this.phone ? [] : this.colBoundaryPositions).map(
                        (pos, i) => html`
            <div
              class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
              style="left: ${pos}px"
              @mousedown=${(e: MouseEvent) =>
                            this.onColResizeStart(e, i)}
            ></div>
          `,
                    )}
      </div>
            </div>
          `}

      <menu-surface
        id="context-menu"
        .active=${this.ctxMenu.contextMenuOpen}
      >
        ${this.ctxMenu.contextMenuOpen
                ? html`
              <div class="context-menu-panel" role="menu" aria-label="Track 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.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
                    }}
                  @mouseleave=${this.ctxMenu.scheduleSubmenuClose}
                  @click=${(e: Event) => {
                        e.stopPropagation();
                        void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
                    }}
                >
                  <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.selection.getSelectedKeysOrdered()) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
                </wa-dropdown-item>
                <wa-dropdown-item
                    @click=${() =>
                        this.onContextMenuAction(
                            'track-details',
                        )}
                     @mouseenter=${() =>
                         this.ctxMenu.closePlaylistSubmenu()}
                >
                    <wa-icon
                        slot="icon"
                        name="circle-info"
                    ></wa-icon>
                    Track Details
                </wa-dropdown-item>
                <wa-dropdown-item
                    @click=${() =>
                        this.onContextMenuAction(
                            'remove-from-library',
                        )}
                     @mouseenter=${() =>
                         this.ctxMenu.closePlaylistSubmenu()}
                >
                    <wa-icon
                        slot="icon"
                        name="trash"
                    ></wa-icon>
                    Remove from Library
                </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 && this.selection.hasSelection
                ? 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>

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