import { LitElement, html, css, nothing } from 'lit';
import {
    customElement,
    state,
    query,
} from 'lit/decorators.js';
import type * as library from '@go/library/models.js';
import {
    formatSampleRate,
    formatBitDepth,
    formatChannels,
    formatBitrate,
    formatFileSize,
} from '@utils/format';
import { formatMilliseconds } from '@utils/time';
import { nameDialogsIn } from '@utils/name-dialog';
import { WriteTrackTagsByPath } from '@go/tagwriter/tagwriter.js';
import {
    BatchWriteTrackTags,
    CancelBatchWrite,
} from '@go/tagwriter/tagwriter.js';
import { GetTrackMBIDs } from '@go/library/library.js';
type TrackMBIDs = library.TrackMBIDs;
import { ImageFilePicker, ReadFile } from '@go/frontendutil/frontendutil.js';
import { libraryStore } from '../../store/library-store';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';

import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';

/** Cover art URLs resolved from the album cache. */
export interface CoverArtUrls {
    coverArtPath: string;
    coverArtSmall: string;
    coverArtMedium: string;
    coverArtLarge: string;
}

/** Editable field definition. */
interface MetadataField {
    key: string;
    label: string;
    value: string;
    editable: boolean;
    type: 'text' | 'number';
    mixed?: boolean;
}

/** Human-readable label map for fields shown in confirmations. */
const FIELD_LABELS: Record<string, string> = {
    title: 'Title',
    artist: 'Artist',
    album: 'Album',
    genre: 'Genre',
    year: 'Year',
    composer: 'Composer',
    trackNumber: 'Track #',
    discNumber: 'Disc #',
};

/**
 * Modal dialog displaying detailed metadata for a single track
 * or a batch of selected tracks.
 *
 * Call `show(track, coverArt?)` for single-track mode.
 * Call `showBatch(tracks, coverArt, coverArtMixed)` for batch mode.
 * Call `close()` to dismiss.
 */
@customElement('track-details')
export class TrackDetails extends LitElement {
    // -- Single-track state --
    @state() private track: library.Track | null = null;
    @state() private coverArt: CoverArtUrls | null = null;

    // -- Shared edit state --
    @state() private editing = false;
    @state() private editValues: Record<string, string> = {};
    @state() private saving = false;
    @state() private errorMessage = '';
    @state() private pendingCoverArt: {
        data: ArrayBuffer;
        previewUrl: string;
    } | null = null;
    @state() private clearCoverArt = false;

    // -- Batch-specific state --
    @state() private batchMode = false;
    @state() private batchTracks: library.Track[] = [];
    @state() private batchFilePaths: string[] = [];
    @state() private batchCoverArtMixed = false;
    @state() private batchProgress: {
        current: number;
        total: number;
    } | null = null;
    @state() private batchResult: {
        succeeded: number;
        failed: number;
        cancelled: boolean;
        failures: Array<{ filePath: string; error: string }>;
    } | null = null;
    @state() private showConfirmation = false;
    @state() private trackMBIDs: TrackMBIDs | null = null;

    @query('wa-dialog')
    private dialog!: HTMLElement & { open: boolean };

    // =================================================================
    // PUBLIC API
    // =================================================================

    /** Open the dialog for a single track. */
    show(
        track: library.Track,
        coverArt?: CoverArtUrls,
    ): void {
        this.track = track;
        this.coverArt = coverArt ?? null;
        this.batchMode = false;
        this.editing = false;
        this.editValues = {};
        this.errorMessage = '';
        this.trackMBIDs = null;
        this.cleanupPendingCoverArt();
        this.resetBatchState();

        // Load MBIDs asynchronously.
        this.loadMBIDs(track.FilePath);

        this.updateComplete.then(() => {
            if (this.dialog) this.dialog.open = true;
        });
    }

    /** Open the dialog for batch editing multiple tracks. */
    showBatch(
        tracks: library.Track[],
        coverArt: CoverArtUrls | null,
        coverArtMixed: boolean,
    ): void {
        this.batchMode = true;
        this.batchTracks = tracks;
        this.batchFilePaths = tracks.map(
            (t) => t.FilePath,
        );
        this.batchCoverArtMixed = coverArtMixed;
        this.coverArt = coverArt;

        // Clear single-track state.
        this.track = null;

        // Reset edit state.
        this.editing = false;
        this.editValues = {};
        this.errorMessage = '';
        this.batchProgress = null;
        this.batchResult = null;
        this.showConfirmation = false;
        this.cleanupPendingCoverArt();

        this.updateComplete.then(() => {
            if (this.dialog) this.dialog.open = true;
        });
    }

    /** Close the dialog. */
    close(): void {
        // Cancel in-flight batch write if active.
        if (this.batchProgress) {
            void CancelBatchWrite();
        }

        if (this.dialog) this.dialog.open = false;
        this.editing = false;
        this.editValues = {};
        this.errorMessage = '';
        this.cleanupPendingCoverArt();
        this.resetBatchState();
    }

    // =================================================================
    // STYLES
    // =================================================================

    static override styles = [designTokens, css`
        wa-dialog {
            --width: 640px;
        }

        wa-dialog::part(dialog) {
            background: var(--yj-bg-surface, #212529);
            color: var(--yj-text-primary, #fff);
            border: 1px solid var(--yj-border, #444);
            border-radius: 8px;
        }

        wa-dialog::part(title) {
            font-size: 16px; /* dialog header — outside type scale */
            font-weight: 600;
            color: var(--yj-text-primary, #fff);
            padding: 16px 20px 8px;
        }

        wa-dialog::part(header-actions) {
            padding: 16px 20px 8px;
        }

        wa-dialog::part(close-button__base) {
            color: var(--yj-text-tertiary, #888);
        }

        wa-dialog::part(body) {
            padding: 0 20px 20px;
        }

        .top-section {
            display: flex;
            gap: 20px;
            margin-bottom: 20px;
        }

        .cover-art {
            width: 200px;
            height: 200px;
            flex-shrink: 0;
            border-radius: 6px;
            overflow: hidden;
        }

        .cover-art img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }

        .cover-placeholder {
            width: 100%;
            height: 100%;
            background-color: var(
                --yj-bg-elevated,
                #343a40
            );
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .cover-placeholder wa-icon {
            color: var(--yj-text-tertiary, #888);
            font-size: 64px; /* large decorative placeholder */
        }

        .main-meta {
            display: flex;
            flex-direction: column;
            gap: 8px;
            min-width: 0;
            flex: 1;
            justify-content: center;
        }

        .main-meta .title {
            font-size: 22px; /* dialog title — outside type scale */
            font-weight: 600;
            color: var(--yj-text-primary, #fff);
            word-break: break-word;
        }

        .main-meta .artist {
            font-size: var(--yj-text-lg);
            color: var(--yj-text-secondary, #b3b3b3);
        }

        .main-meta .album {
            font-size: var(--yj-text-lg);
            color: var(--yj-text-tertiary, #888);
        }

        .main-meta .duration {
            font-size: var(--yj-text-md);
            color: var(--yj-text-tertiary, #888);
            font-variant-numeric: tabular-nums;
        }

        .divider {
            height: 1px;
            background: var(--yj-border-subtle, #333);
            margin-bottom: 16px;
        }

        .section-label {
            font-size: var(--yj-text-xs);
            font-weight: 600;
            color: var(--yj-text-tertiary, #888);
            text-transform: uppercase;
            letter-spacing: 0.8px;
            margin-bottom: 10px;
        }

        .metadata-grid {
            display: grid;
            grid-template-columns: 120px 1fr;
            gap: 8px 12px;
            align-items: baseline;
        }

        .meta-label {
            font-size: var(--yj-text-sm);
            font-weight: 500;
            color: var(--yj-text-tertiary, #888);
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        .meta-value {
            font-size: var(--yj-text-md);
            color: var(--yj-text-secondary, #b3b3b3);
            word-break: break-word;
        }

        .meta-value.empty {
            color: var(--yj-text-tertiary, #888);
            font-style: italic;
        }

        /* MusicBrainz badge + links */
        .mb-verified-badge {
            color: var(--yj-success-text, #51cf66);
            font-size: 14px;
            margin-left: 6px;
            vertical-align: middle;
            cursor: help;
        }

        .mb-section-header {
            display: flex;
            align-items: center;
            gap: 6px;
        }

        .mb-icon {
            color: var(--yj-success-text, #51cf66);
            font-size: 14px;
        }

        .mb-link {
            font-size: var(--yj-text-xs);
            color: var(--yj-text-secondary, #b3b3b3);
            text-decoration: none;
            word-break: break-all;
            font-family: monospace;
        }

        .mb-link:hover {
            color: var(--yj-success-text, #51cf66);
            text-decoration: underline;
        }

        /* Edit mode inputs */
        .meta-input {
            width: 100%;
            box-sizing: border-box;
            background: var(--yj-bg-elevated, #343a40);
            border: 1px solid
                var(--yj-border-subtle, #333);
            border-radius: 4px;
            color: var(--yj-text-primary, #fff);
            font-size: var(--yj-text-md);
            padding: 4px 8px;
            font-family: inherit;
        }

        .meta-input:focus {
            outline: none;
            border-color: var(--yj-accent, #ffd43b);
        }

        .meta-input::placeholder {
            color: var(--yj-text-tertiary, #888);
            font-style: italic;
        }

        .main-input {
            background: var(--yj-bg-elevated, #343a40);
            border: 1px solid
                var(--yj-border-subtle, #333);
            border-radius: 4px;
            color: var(--yj-text-primary, #fff);
            font-family: inherit;
            padding: 4px 8px;
            width: 100%;
            box-sizing: border-box;
        }

        .main-input:focus {
            outline: none;
            border-color: var(--yj-accent, #ffd43b);
        }

        .main-input::placeholder {
            color: var(--yj-text-tertiary, #888);
            font-style: italic;
        }

        .main-input.title-input {
            font-size: 20px; /* edit mode title — outside type scale */
            font-weight: 600;
        }

        .main-input.artist-input {
            font-size: var(--yj-text-lg);
        }

        .main-input.album-input {
            font-size: var(--yj-text-md);
        }

        /* Action bar */
        .action-bar {
            display: flex;
            justify-content: flex-end;
            gap: 8px;
            margin-top: 16px;
        }

        .btn {
            padding: 6px 16px;
            border-radius: 4px;
            border: 1px solid var(--yj-border, #444);
            background: var(
                --yj-bg-elevated,
                #343a40
            );
            color: var(--yj-text-primary, #fff);
            font-size: var(--yj-text-md);
            cursor: pointer;
            font-family: inherit;
            transition: background-color 0.15s ease;
        }

        .btn:hover {
            background: var(--yj-bg-overlay, #495057);
        }

        .btn-primary {
            background: var(--yj-accent, #ffd43b);
            color: var(--yj-accent-fg, #000);
            border-color: var(--yj-accent, #ffd43b);
        }

        .btn-primary:hover {
            background: var(
                --yj-accent-hover,
                #ffe066
            );
            border-color: var(
                --yj-accent-hover,
                #ffe066
            );
        }

        .btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }

        .btn-danger {
            color: var(--yj-error-text, #ff8787);
            border-color: var(--yj-error, #e03131);
        }

        .btn-danger:hover {
            background: var(--yj-error, #e03131);
            color: var(--yj-error-fg, #fff);
        }

        /* Cover art edit mode */
        .cover-art-edit {
            cursor: pointer;
            position: relative;
        }

        .cover-art-overlay {
            position: absolute;
            inset: 0;
            background: rgba(0, 0, 0, 0.5);
            display: flex;
            align-items: center;
            justify-content: center;
            opacity: 0;
            transition: opacity 0.15s ease;
            border-radius: 6px;
        }

        .cover-art-edit:hover .cover-art-overlay {
            opacity: 1;
        }

        .cover-art-overlay wa-icon {
            color: #fff;
            font-size: 32px;
        }

        .cover-art-remove {
            position: absolute;
            top: 4px;
            right: 4px;
            width: 24px;
            height: 24px;
            border-radius: 50%;
            border: none;
            background: rgba(0, 0, 0, 0.7);
            color: #fff;
            font-size: 14px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            opacity: 0;
            transition: opacity 0.15s ease;
        }

        .cover-art-edit:hover .cover-art-remove {
            opacity: 1;
        }

        .cover-art-remove:hover {
            background: var(--yj-error, #e03131);
        }

        /*
         * Both are the *only* route to changing or removing a track's
         * cover art, so where the device has no hover they are always
         * visible rather than hidden — the inverse of #68's rule, which
         * applies where the hover control is redundant. Revealed by
         * opacity, so what is on screen is what the desktop reveal shows
         * and nothing about the layout moves.
         */
        @media not all and (hover: hover) {
            /* The × is genuinely the only route to removing the art, so
               on a device that cannot hover it is simply always there.

               The pen is not: .cover-art-edit carries the click that
               opens the file picker, so tapping the artwork already
               worked while the overlay was invisible. It is a discovery
               hint — and paying for discovery by covering the artwork
               being edited in 50% black, permanently, on every touch
               device, is heavier than the hint is worth. It becomes a
               corner chip in the remove button's own visual language
               instead: same size, same disc, same alpha. */
            .cover-art-remove {
                opacity: 1;
            }

            .cover-art-overlay {
                opacity: 1;
                inset: auto 4px 4px auto;
                width: 24px;
                height: 24px;
                border-radius: 50%;
                background: rgba(0, 0, 0, 0.7);
            }

            .cover-art-overlay wa-icon {
                font-size: 14px;
            }
        }

        /* Error message */
        .error-message {
            flex: 1;
            color: var(--yj-error-text, #ff8787);
            font-size: var(--yj-text-sm);
            padding: 4px 0;
            word-break: break-word;
        }

        /* ---- Batch mode styles ---- */

        .batch-header {
            font-size: 22px;
            font-weight: 600;
            color: var(--yj-text-primary, #fff);
        }

        .batch-subheader {
            font-size: var(--yj-text-md);
            color: var(--yj-text-tertiary, #888);
        }

        .main-field-group {
            display: flex;
            flex-direction: column;
            gap: 2px;
        }

        .main-field-label {
            font-size: var(--yj-text-xs);
            font-weight: 500;
            color: var(--yj-text-tertiary, #888);
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        .mixed-value {
            color: var(--yj-text-tertiary, #888);
            font-style: italic;
        }

        .cover-art-mixed {
            width: 100%;
            height: 100%;
            background-color: var(--yj-bg-elevated, #343a40);
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }

        .cover-art-mixed wa-icon {
            color: var(--yj-text-tertiary, #888);
            font-size: 48px;
        }

        .cover-art-mixed span {
            color: var(--yj-text-tertiary, #888);
            font-size: var(--yj-text-xs);
            font-style: italic;
        }

        /* Confirmation overlay */
        .confirmation-overlay {
            position: absolute;
            inset: 0;
            background: rgba(0, 0, 0, 0.6);
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 10;
            border-radius: 8px;
        }

        .confirmation-content {
            background: var(--yj-bg-surface, #212529);
            border: 1px solid var(--yj-border, #444);
            border-radius: 8px;
            padding: 20px;
            max-width: 420px;
            width: 90%;
        }

        .confirmation-content h3 {
            margin: 0 0 16px;
            font-size: var(--yj-text-lg);
            color: var(--yj-text-primary, #fff);
        }

        .confirmation-summary {
            margin-bottom: 16px;
            font-size: var(--yj-text-md);
            color: var(--yj-text-secondary, #b3b3b3);
        }

        .confirmation-summary .change-item {
            padding: 4px 0;
            border-bottom: 1px solid var(--yj-border-subtle, #333);
        }

        .confirmation-summary .change-item:last-child {
            border-bottom: none;
        }

        .confirmation-actions {
            display: flex;
            justify-content: flex-end;
            gap: 8px;
        }

        /* Progress bar */
        .batch-progress {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 12px;
            padding: 40px 20px;
        }

        .progress-text {
            font-size: var(--yj-text-lg);
            color: var(--yj-text-primary, #fff);
            font-variant-numeric: tabular-nums;
        }

        .progress-bar-track {
            width: 100%;
            height: 8px;
            background: var(--yj-bg-elevated, #343a40);
            border-radius: 4px;
            overflow: hidden;
        }

        .progress-bar-fill {
            height: 100%;
            background: var(--yj-accent, #4a9eff);
            border-radius: 4px;
            transition: width 0.3s ease;
        }

        /* Batch results */
        .batch-result {
            padding: 20px 0;
        }

        .batch-result .result-title {
            font-size: var(--yj-text-lg);
            font-weight: 600;
            color: var(--yj-text-primary, #fff);
            margin-bottom: 12px;
        }

        .batch-result .result-success {
            color: var(--yj-success-text, #51cf66);
        }

        .batch-result .result-partial {
            color: var(--yj-warning-text, #ffa94d);
        }

        .batch-result .result-cancelled {
            color: var(--yj-text-tertiary, #888);
        }

        .failure-list {
            margin-top: 12px;
            font-size: var(--yj-text-sm);
        }

        .failure-list summary {
            cursor: pointer;
            color: var(--yj-error-text, #ff8787);
            font-weight: 500;
            margin-bottom: 8px;
        }

        .failure-item {
            padding: 4px 0;
            color: var(--yj-text-secondary, #b3b3b3);
            word-break: break-all;
        }

        .failure-item .failure-path {
            font-weight: 500;
            color: var(--yj-text-primary, #fff);
        }

        .failure-item .failure-error {
            color: var(--yj-error-text, #ff8787);
            font-style: italic;
        }
    `];

    // =================================================================
    // RENDER
    // =================================================================

    /**
     * Web Awesome renders `label` into a heading it never points the
     * `<dialog>` at, so the dialog has no accessible name until
     * something sets one. See `utils/name-dialog.ts`.
     */
    override updated() {
        nameDialogsIn(this.shadowRoot);
    }

    override render() {
        const label = this.batchMode
            ? 'Batch Edit'
            : 'Track Details';

        return html`
            <wa-dialog label="${label}">
                ${this.batchMode
                    ? this.renderBatchContent()
                    : this.track
                        ? this.renderContent()
                        : nothing}
            </wa-dialog>
        `;
    }

    // -- Single-track render (unchanged) --

    private renderContent() {
        const t = this.track!;

        return html`
            <div class="top-section">
                ${this.renderCoverArt()}
                <div class="main-meta">
                    ${this.renderMainFields(t)}
                </div>
            </div>
            <div class="divider"></div>
            <div class="metadata-grid">
                ${this.renderDetailFields(t)}
            </div>
            <div class="divider"></div>
            <div class="section-label">
                Audio Properties
            </div>
            <div class="metadata-grid">
                ${this.renderAudioProperties(t)}
            </div>
            ${this.renderMusicBrainzSection()}
            <div class="action-bar">
                ${this.renderActions()}
            </div>
        `;
    }

    // -- Batch render --

    private renderBatchContent() {
        if (this.batchProgress) {
            return this.renderBatchProgress();
        }

        if (this.batchResult) {
            return this.renderBatchResult();
        }

        const fields = this.getMergedFields();

        return html`
            <div class="top-section">
                ${this.renderBatchCoverArt()}
                <div class="main-meta">
                    ${this.renderBatchMainFields(fields)}
                </div>
            </div>
            <div class="divider"></div>
            <div class="metadata-grid">
                ${this.renderBatchDetailFields(fields)}
            </div>
            <div class="action-bar">
                ${this.renderBatchActions()}
            </div>
            ${this.showConfirmation
                ? this.renderConfirmation()
                : nothing}
        `;
    }

    private renderBatchCoverArt() {
        if (this.editing) {
            return this.renderCoverArtEditable();
        }

        if (this.batchCoverArtMixed && !this.coverArt) {
            return html`
                <div class="cover-art">
                    <div class="cover-art-mixed">
                        <wa-icon name="images"></wa-icon>
                        <span>Multiple cover arts</span>
                    </div>
                </div>
            `;
        }

        const src =
            this.coverArt?.coverArtLarge ??
            this.coverArt?.coverArtMedium ??
            this.coverArt?.coverArtPath;

        if (!src) {
            return html`
                <div class="cover-art">
                    <div class="cover-placeholder">
                        <wa-icon
                            name="music"
                        ></wa-icon>
                    </div>
                </div>
            `;
        }

        return html`
            <div class="cover-art">
                <img
                    src="${src}"
                    alt="Album cover"
                    @error=${this.handleImageError}
                />
            </div>
        `;
    }

    private renderBatchMainFields(
        fields: MetadataField[],
    ) {
        const titleField = fields.find(
            (f) => f.key === 'title',
        );
        const artistField = fields.find(
            (f) => f.key === 'artist',
        );
        const albumField = fields.find(
            (f) => f.key === 'album',
        );

        if (this.editing) {
            return html`
                <span class="batch-header">
                    Editing ${this.batchTracks.length} tracks
                </span>
                <div class="main-field-group">
                    <label class="main-field-label">Title</label>
                    <input
                        class="main-input title-input"
                        .value=${this.getEditValue(
                            'title',
                            titleField?.value ?? '',
                        )}
                        @input=${(e: Event) =>
                            this.onEditInput('title', e)}
                        placeholder=${titleField?.mixed
                            ? 'Multiple values'
                            : 'Title'}
                    />
                </div>
                <div class="main-field-group">
                    <label class="main-field-label">Artist</label>
                    <input
                        class="main-input artist-input"
                        .value=${this.getEditValue(
                            'artist',
                            artistField?.value ?? '',
                        )}
                        @input=${(e: Event) =>
                            this.onEditInput('artist', e)}
                        placeholder=${artistField?.mixed
                            ? 'Multiple values'
                            : 'Artist'}
                    />
                </div>
                <div class="main-field-group">
                    <label class="main-field-label">Album</label>
                    <input
                        class="main-input album-input"
                        .value=${this.getEditValue(
                            'album',
                            albumField?.value ?? '',
                        )}
                        @input=${(e: Event) =>
                            this.onEditInput('album', e)}
                        placeholder=${albumField?.mixed
                            ? 'Multiple values'
                            : 'Album'}
                    />
                </div>
            `;
        }

        return html`
            <span class="batch-header">
                ${this.batchTracks.length} tracks selected
            </span>
            <div class="main-field-group">
                <label class="main-field-label">Title</label>
                ${titleField?.mixed
                    ? html`<span class="mixed-value">
                          ${this.countDistinctValues('title')} different titles
                      </span>`
                    : html`<span class="title">
                          ${titleField?.value || 'None'}
                      </span>`}
            </div>
            <div class="main-field-group">
                <label class="main-field-label">Artist</label>
                ${artistField?.mixed
                    ? html`<span class="mixed-value">
                          ${this.countDistinctValues('artist')} different artists
                      </span>`
                    : html`<span class="artist">
                          ${artistField?.value || 'Unknown Artist'}
                      </span>`}
            </div>
            <div class="main-field-group">
                <label class="main-field-label">Album</label>
                ${albumField?.mixed
                    ? html`<span class="mixed-value">
                          ${this.countDistinctValues('album')} different albums
                      </span>`
                    : html`<span class="album">
                          ${albumField?.value || 'No album'}
                      </span>`}
            </div>
        `;
    }

    private renderBatchDetailFields(
        fields: MetadataField[],
    ) {
        // Only show editable detail fields in batch mode
        // (genre, year, composer, track#, disc#).
        const detailFields = fields.filter(
            (f) =>
                f.editable &&
                f.key !== 'title' &&
                f.key !== 'artist' &&
                f.key !== 'album',
        );

        return detailFields.map((f) =>
            this.renderBatchField(f),
        );
    }

    private renderBatchField(f: MetadataField) {
        if (this.editing) {
            return html`
                <span class="meta-label">${f.label}</span>
                <input
                    class="meta-input"
                    type=${f.type}
                    .value=${this.getEditValue(
                        f.key,
                        f.value,
                    )}
                    @input=${(e: Event) =>
                        this.onEditInput(f.key, e)}
                    placeholder=${f.mixed
                        ? 'Multiple values'
                        : ''}
                />
            `;
        }

        if (f.mixed) {
            return html`
                <span class="meta-label">${f.label}</span>
                <span class="meta-value mixed-value">
                    ${this.countDistinctValues(f.key)} different values
                </span>
            `;
        }

        const display = f.value;

        return html`
            <span class="meta-label">${f.label}</span>
            <span
                class="meta-value ${display
                    ? ''
                    : 'empty'}"
            >
                ${display || 'None'}
            </span>
        `;
    }

    private renderBatchActions() {
        if (this.editing) {
            return html`
                ${this.errorMessage
                    ? html`<div class="error-message">
                          ${this.errorMessage}
                      </div>`
                    : nothing}
                <button
                    class="btn"
                    @click=${this.cancelEdit}
                    ?disabled=${this.saving}
                >
                    Cancel
                </button>
                <button
                    class="btn btn-primary"
                    @click=${this.saveBatchEdit}
                    ?disabled=${this.saving}
                >
                    ${this.saving ? 'Saving…' : 'Save'}
                </button>
            `;
        }

        return html`
            <button
                class="btn"
                @click=${this.startEdit}
            >
                <wa-icon name="pen-to-square"></wa-icon>
                Edit
            </button>
        `;
    }

    private renderConfirmation() {
        const changes = this.getConfirmationSummary();

        return html`
            <div class="confirmation-overlay">
                <div class="confirmation-content">
                    <h3>Apply changes to ${this.batchTracks.length} tracks?</h3>
                    <div class="confirmation-summary">
                        ${changes.map(
                            (c) => html`
                                <div class="change-item">
                                    ${c}
                                </div>
                            `,
                        )}
                    </div>
                    <div class="confirmation-actions">
                        <button
                            class="btn"
                            @click=${this.cancelConfirmation}
                        >
                            Cancel
                        </button>
                        <button
                            class="btn btn-primary"
                            @click=${this.confirmSave}
                        >
                            Apply
                        </button>
                    </div>
                </div>
            </div>
        `;
    }

    private renderBatchProgress() {
        const progress = this.batchProgress!;
        const pct =
            progress.total > 0
                ? (progress.current / progress.total) * 100
                : 0;

        return html`
            <div class="batch-progress">
                <div class="progress-text">
                    ${progress.current} of ${progress.total} tracks
                </div>
                <div class="progress-bar-track">
                    <div
                        class="progress-bar-fill"
                        style="width: ${pct}%"
                    ></div>
                </div>
                <button
                    class="btn btn-danger"
                    @click=${this.cancelBatchWrite}
                >
                    Cancel
                </button>
            </div>
        `;
    }

    private renderBatchResult() {
        const r = this.batchResult!;

        let titleClass = 'result-success';
        let titleText = `All ${r.succeeded} tracks updated successfully`;

        if (r.cancelled) {
            titleClass = 'result-cancelled';
            titleText = `Batch cancelled \u2014 ${r.succeeded} of ${r.succeeded + r.failed + (this.batchFilePaths.length - r.succeeded - r.failed)} tracks updated`;
        } else if (r.failed > 0) {
            titleClass = 'result-partial';
            titleText = `${r.succeeded} tracks updated, ${r.failed} failed`;
        }

        return html`
            <div class="batch-result">
                <div class="result-title ${titleClass}">
                    ${titleText}
                </div>
                ${r.failures.length > 0
                    ? html`
                          <details class="failure-list">
                              <summary>
                                  ${r.failures.length} failure${r.failures.length > 1 ? 's' : ''}
                              </summary>
                              ${r.failures.map(
                                  (f) => html`
                                      <div class="failure-item">
                                          <span class="failure-path">
                                              ${this.fileNameFromPath(f.filePath)}
                                          </span>
                                          \u2014
                                          <span class="failure-error">
                                              ${f.error}
                                          </span>
                                      </div>
                                  `,
                              )}
                          </details>
                      `
                    : nothing}
                <div class="action-bar">
                    <button
                        class="btn"
                        @click=${this.closeBatchResult}
                    >
                        Close
                    </button>
                </div>
            </div>
        `;
    }

    // -- Single-track render helpers (unchanged) --

    private renderCoverArt() {
        if (this.editing) {
            return this.renderCoverArtEditable();
        }

        const src =
            this.coverArt?.coverArtLarge ??
            this.coverArt?.coverArtMedium ??
            this.coverArt?.coverArtPath;

        if (!src) {
            return html`
                <div class="cover-art">
                    <div class="cover-placeholder">
                        <wa-icon
                            name="music"
                        ></wa-icon>
                    </div>
                </div>
            `;
        }

        return html`
            <div class="cover-art">
                <img
                    src="${src}"
                    alt="Album cover"
                    @error=${this.handleImageError}
                />
            </div>
        `;
    }

    private renderCoverArtEditable() {
        const showRemove =
            !this.clearCoverArt &&
            (this.pendingCoverArt || this.coverArt);

        // Determine which image to show.
        let src: string | undefined;

        if (this.clearCoverArt) {
            src = undefined; // Show placeholder.
        } else if (this.pendingCoverArt) {
            src = this.pendingCoverArt.previewUrl;
        } else {
            src =
                this.coverArt?.coverArtLarge ??
                this.coverArt?.coverArtMedium ??
                this.coverArt?.coverArtPath;
        }

        return html`
            <div
                class="cover-art cover-art-edit"
                @click=${this.selectCoverArt}
            >
                ${src
                    ? html`<img
                          src="${src}"
                          alt="Album cover"
                          @error=${this.handleImageError}
                      />`
                    : html`<div class="cover-placeholder">
                          <wa-icon
                              name="music"
                          ></wa-icon>
                      </div>`}
                <div class="cover-art-overlay">
                    <wa-icon
                        name="pen-to-square"
                    ></wa-icon>
                </div>
                ${showRemove
                    ? html`<button
                          class="cover-art-remove"
                          @click=${(e: Event) => {
                              e.stopPropagation();
                              this.removeCoverArt();
                          }}
                          title="Remove cover art"
                      >
                          ×
                      </button>`
                    : nothing}
            </div>
        `;
    }

    private handleImageError = (e: Event) => {
        const img = e.target as HTMLImageElement;
        const fallback = this.coverArt?.coverArtPath;

        if (fallback && img.src !== fallback) {
            img.src = fallback;

            return;
        }

        const container = img.parentElement;

        if (container) {
            container.innerHTML =
                '<div class="cover-placeholder">' +
                '<wa-icon name="music"></wa-icon>' +
                '</div>';
        }
    };

    private renderMainFields(t: library.Track) {
        if (this.editing) {
            return html`
                <div class="main-field-group">
                    <label class="main-field-label">Title</label>
                    <input
                        class="main-input title-input"
                        .value=${this.getEditValue(
                            'title',
                            t.TrackName,
                        )}
                        @input=${(e: Event) =>
                            this.onEditInput(
                                'title',
                                e,
                            )}
                        placeholder="Title"
                    />
                </div>
                <div class="main-field-group">
                    <label class="main-field-label">Artist</label>
                    <input
                        class="main-input artist-input"
                        .value=${this.getEditValue(
                            'artist',
                            t.ArtistName,
                        )}
                        @input=${(e: Event) =>
                            this.onEditInput(
                                'artist',
                                e,
                            )}
                        placeholder="Artist"
                    />
                </div>
                <div class="main-field-group">
                    <label class="main-field-label">Album</label>
                    <input
                        class="main-input album-input"
                        .value=${this.getEditValue(
                            'album',
                            t.Album,
                        )}
                        @input=${(e: Event) =>
                            this.onEditInput(
                                'album',
                                e,
                            )}
                        placeholder="Album"
                    />
                </div>
                <span class="duration">
                    ${formatMilliseconds(t.TrackLength)}
                </span>
            `;
        }

        return html`
            <div class="main-field-group">
                <label class="main-field-label">Title</label>
                <span class="title">
                    ${t.TrackName || this.fileNameFromPath(t.FilePath)}
                    ${this.trackMBIDs?.recordingMbid
                        ? html`<span
                              class="mb-verified-badge"
                              title="Metadata verified by MusicBrainz"
                          >
                              <wa-icon name="circle-check"></wa-icon>
                          </span>`
                        : nothing}
                </span>
            </div>
            <div class="main-field-group">
                <label class="main-field-label">Artist</label>
                <span class="artist">
                    ${t.ArtistName || 'Unknown Artist'}
                </span>
            </div>
            <div class="main-field-group">
                <label class="main-field-label">Album</label>
                ${t.Album
                    ? html`<span class="album"
                          >${t.Album}</span
                      >`
                    : html`<span class="album" style="font-style: italic"
                          >No album</span
                      >`}
            </div>
            <span class="duration">
                ${formatMilliseconds(t.TrackLength)}
            </span>
        `;
    }

    private renderDetailFields(t: library.Track) {
        const fields: MetadataField[] = [
            {
                key: 'genre',
                label: 'Genre',
                value: (t.Genre ?? []).join(', '),
                editable: true,
                type: 'text',
            },
            {
                key: 'year',
                label: 'Year',
                value: t.Year ? String(t.Year) : '',
                editable: true,
                type: 'number',
            },
            {
                key: 'composer',
                label: 'Composer',
                value: t.Composer ?? '',
                editable: true,
                type: 'text',
            },
            {
                key: 'trackNumber',
                label: 'Track #',
                value: t.TrackNumber
                    ? String(t.TrackNumber)
                    : '',
                editable: true,
                type: 'number',
            },
            {
                key: 'discNumber',
                label: 'Disc #',
                value: t.DiscNumber
                    ? String(t.DiscNumber)
                    : '',
                editable: true,
                type: 'number',
            },
            {
                key: 'fileType',
                label: 'File Type',
                value: t.FileType ?? '',
                editable: false,
                type: 'text',
            },
            {
                key: 'filePath',
                label: 'File Path',
                value: t.FilePath ?? '',
                editable: false,
                type: 'text',
            },
        ];

        return fields.map((f) => this.renderField(f));
    }

    private renderAudioProperties(t: library.Track) {
        const props: { label: string; value: string }[] = [
            {
                label: 'Sample Rate',
                value: formatSampleRate(t.SampleRate),
            },
            {
                label: 'Bit Depth',
                value: formatBitDepth(t.BitDepth),
            },
            {
                label: 'Channels',
                value: formatChannels(t.Channels),
            },
            {
                label: 'Bitrate',
                value: formatBitrate(t.Bitrate),
            },
            {
                label: 'File Size',
                value: formatFileSize(t.FileSize),
            },
        ];

        return props.map(
            (p) => html`
                <span class="meta-label">${p.label}</span>
                <span
                    class="meta-value ${p.value ===
                    '\u2014'
                        ? 'empty'
                        : ''}"
                >
                    ${p.value}
                </span>
            `,
        );
    }

    private renderMusicBrainzSection() {
        if (!this.trackMBIDs) return nothing;

        const mbids = this.trackMBIDs;
        const links: Array<{ label: string; mbid: string; type: string }> = [];

        if (mbids.recordingMbid) {
            links.push({
                label: 'Recording',
                mbid: mbids.recordingMbid,
                type: 'recording',
            });
        }

        if (mbids.releaseGroupMbid) {
            links.push({
                label: 'Release Group',
                mbid: mbids.releaseGroupMbid,
                type: 'release-group',
            });
        }

        if (mbids.artistMbid) {
            links.push({
                label: 'Artist',
                mbid: mbids.artistMbid,
                type: 'artist',
            });
        }

        if (links.length === 0) return nothing;

        return html`
            <div class="section-header mb-section-header">
                <wa-icon name="circle-check" class="mb-icon"></wa-icon>
                MusicBrainz
            </div>
            <div class="metadata-grid">
                ${links.map(
                    (l) => html`
                        <span class="meta-label">${l.label}</span>
                        <a
                            class="mb-link"
                            href="https://musicbrainz.org/${l.type}/${l.mbid}"
                            target="_blank"
                            title="View on MusicBrainz"
                        >
                            ${l.mbid}
                        </a>
                    `,
                )}
            </div>
        `;
    }

    private renderField(f: MetadataField) {
        const display =
            this.getEditValue(f.key, f.value) || f.value;

        return html`
            <span class="meta-label">${f.label}</span>
            ${this.editing && f.editable
                ? html`
                      <input
                          class="meta-input"
                          type=${f.type}
                          .value=${this.getEditValue(
                              f.key,
                              f.value,
                          )}
                          @input=${(e: Event) =>
                              this.onEditInput(
                                  f.key,
                                  e,
                              )}
                      />
                  `
                : html`
                      <span
                          class="meta-value ${display
                              ? ''
                              : 'empty'}"
                      >
                          ${display || 'None'}
                      </span>
                  `}
        `;
    }

    private renderActions() {
        if (this.editing) {
            return html`
                ${this.errorMessage
                    ? html`<div class="error-message">
                          ${this.errorMessage}
                      </div>`
                    : nothing}
                <button
                    class="btn"
                    @click=${this.cancelEdit}
                    ?disabled=${this.saving}
                >
                    Cancel
                </button>
                <button
                    class="btn btn-primary"
                    @click=${this.saveEdit}
                    ?disabled=${this.saving}
                >
                    ${this.saving ? 'Saving…' : 'Save'}
                </button>
            `;
        }

        return html`
            <button
                class="btn"
                @click=${this.startEdit}
            >
                <wa-icon name="pen-to-square"></wa-icon>
                Edit
            </button>
        `;
    }

    // =================================================================
    // EDIT LOGIC
    // =================================================================

    private startEdit = () => {
        this.editing = true;
        this.editValues = {};
        this.errorMessage = '';
        this.cleanupPendingCoverArt();
    };

    private cancelEdit = () => {
        this.exitEditMode();
    };

    // -- Single-track save --

    private saveEdit = async () => {
        if (this.batchMode) {
            this.saveBatchEdit();

            return;
        }

        if (!this.track || this.saving) return;

        this.saving = true;
        this.errorMessage = '';

        try {
            const changes = this.buildChanges();

            if (Object.keys(changes).length === 0) {
                // No actual changes — just exit edit mode.
                this.exitEditMode();

                return;
            }

            const filePath = this.track.FilePath;

            await WriteTrackTagsByPath(filePath, changes);

            // Success — switch to read-only view. The
            // TrackMetadataChanged event triggers library store
            // invalidation, which refreshes all other views.
            this.exitEditMode();

            // Re-fetch track and album data so the dialog
            // shows updated values and cover art.  The store
            // invalidation is already in-flight from the event;
            // these calls await the pending fetch or start one.
            // Awaited for the side effect of refreshing the
            // store; the album list is consumed elsewhere.
            const [tracks] = await Promise.all([
                libraryStore.getTracks(),
                libraryStore.getAlbums(),
            ]);

            const updated = tracks.find(
                (t) => t.FilePath === filePath,
            );

            if (updated) {
                this.track = updated;

                // Re-resolve cover art from the refreshed track data.
                if (updated.CoverArtPath) {
                    this.coverArt = {
                        coverArtPath: updated.CoverArtPath,
                        coverArtSmall: updated.CoverArtSmall,
                        coverArtMedium: updated.CoverArtMedium,
                        coverArtLarge: updated.CoverArtLarge,
                    };
                } else {
                    this.coverArt = null;
                }
            }
        } catch (err: unknown) {
            const msg =
                err instanceof Error
                    ? err.message
                    : String(err);
            this.errorMessage = msg;
        } finally {
            this.saving = false;
        }
    };

    // -- Batch save --

    private saveBatchEdit = () => {
        if (!this.batchMode) return;

        const changes = this.buildBatchChanges();

        if (Object.keys(changes).length === 0) {
            this.exitEditMode();

            return;
        }

        // Show confirmation before executing.
        this.showConfirmation = true;
    };

    private cancelConfirmation = () => {
        this.showConfirmation = false;
    };

    private confirmSave = async () => {
        this.showConfirmation = false;
        this.batchProgress = {
            current: 0,
            total: this.batchFilePaths.length,
        };
        this.editing = false;

        const changes = this.buildBatchChanges();

        // Listen for progress events. Kept as the unsubscribe function
        // EventsOn returns, rather than EventsOff(name), which removes
        // *every* listener for that event (errors.p3).
        const stopProgress = EventsOn(
            Events.BatchWriteProgress,
            (data: {
                current: number;
                total: number;
            }) => {
                this.batchProgress = {
                    current: data.current,
                    total: data.total,
                };
            },
        );

        try {
            const result = await BatchWriteTrackTags(
                this.batchFilePaths,
                changes,
            );

            this.batchResult = {
                succeeded: result.succeeded,
                failed: result.failed,
                cancelled: result.cancelled,
                failures: (result.failures ?? []).map(
                    (f) => ({
                        filePath: f.filePath,
                        error: f.error,
                    }),
                ),
            };
        } catch (err: unknown) {
            const msg =
                err instanceof Error
                    ? err.message
                    : String(err);
            this.batchResult = {
                succeeded: 0,
                failed: this.batchFilePaths.length,
                cancelled: false,
                failures: [
                    {
                        filePath: '(batch)',
                        error: msg,
                    },
                ],
            };
        } finally {
            stopProgress();
            this.batchProgress = null;
        }
    };

    private cancelBatchWrite = () => {
        void CancelBatchWrite();
    };

    private closeBatchResult = async () => {
        this.batchResult = null;
        this.batchProgress = null;
        this.editValues = {};
        this.errorMessage = '';
        this.cleanupPendingCoverArt();

        // Refresh data from library store; album list is
        // refreshed for side effects only.
        const [tracks] = await Promise.all([
            libraryStore.getTracks(),
            libraryStore.getAlbums(),
        ]);

        // Re-resolve batch tracks.
        const pathSet = new Set(this.batchFilePaths);
        const refreshed = tracks.filter((t) =>
            pathSet.has(t.FilePath),
        );
        this.batchTracks = refreshed;

        // Re-resolve cover art state.
        const first = refreshed[0];
        const albumNames = new Set(refreshed.map((t) => t.Album));

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

    // -- Shared edit logic --

    private buildChanges(): Record<string, unknown> {
        const t = this.track!;
        const changes: Record<string, unknown> = {};

        // Map frontend edit keys to backend field constants
        // and original values.
        const fieldMap: Array<{
            editKey: string;
            backendKey: string;
            original: string;
            transform?: (v: string) => unknown;
        }> = [
            {
                editKey: 'title',
                backendKey: 'title',
                original: t.TrackName,
            },
            {
                editKey: 'artist',
                backendKey: 'artist',
                original: t.ArtistName,
            },
            {
                editKey: 'album',
                backendKey: 'album',
                original: t.Album,
            },
            {
                editKey: 'genre',
                backendKey: 'genre',
                original: (t.Genre ?? []).join(', '),
            },
            {
                editKey: 'year',
                backendKey: 'year',
                original: t.Year ? String(t.Year) : '',
                transform: (v) =>
                    v ? parseInt(v, 10) : 0,
            },
            {
                editKey: 'composer',
                backendKey: 'composer',
                original: t.Composer ?? '',
            },
            {
                editKey: 'trackNumber',
                backendKey: 'track_number',
                original: t.TrackNumber
                    ? String(t.TrackNumber)
                    : '',
                transform: (v) =>
                    v ? parseInt(v, 10) : 0,
            },
            {
                editKey: 'discNumber',
                backendKey: 'disc_number',
                original: t.DiscNumber
                    ? String(t.DiscNumber)
                    : '',
                transform: (v) =>
                    v ? parseInt(v, 10) : 0,
            },
        ];

        for (const {
            editKey,
            backendKey,
            original,
            transform,
        } of fieldMap) {
            if (editKey in this.editValues) {
                const newVal = this.editValues[editKey]!;

                if (newVal !== original) {
                    changes[backendKey] = transform
                        ? transform(newVal)
                        : newVal;
                }
            }
        }

        // Cover art changes.
        if (this.pendingCoverArt) {
            // Convert ArrayBuffer to number[] for JSON
            // serialization (Wails passes as []byte on Go side).
            changes['cover_art'] = Array.from(
                new Uint8Array(this.pendingCoverArt.data),
            );
        } else if (this.clearCoverArt) {
            changes['cover_art'] = null;
        }

        return changes;
    }

    private buildBatchChanges(): Record<string, unknown> {
        const changes: Record<string, unknown> = {};

        const fieldMap: Array<{
            editKey: string;
            backendKey: string;
            transform?: (v: string) => unknown;
        }> = [
            { editKey: 'title', backendKey: 'title' },
            { editKey: 'artist', backendKey: 'artist' },
            { editKey: 'album', backendKey: 'album' },
            { editKey: 'genre', backendKey: 'genre' },
            {
                editKey: 'year',
                backendKey: 'year',
                transform: (v) =>
                    v ? parseInt(v, 10) : 0,
            },
            {
                editKey: 'composer',
                backendKey: 'composer',
            },
            {
                editKey: 'trackNumber',
                backendKey: 'track_number',
                transform: (v) =>
                    v ? parseInt(v, 10) : 0,
            },
            {
                editKey: 'discNumber',
                backendKey: 'disc_number',
                transform: (v) =>
                    v ? parseInt(v, 10) : 0,
            },
        ];

        for (const {
            editKey,
            backendKey,
            transform,
        } of fieldMap) {
            if (editKey in this.editValues) {
                const val = this.editValues[editKey]!;
                changes[backendKey] = transform
                    ? transform(val)
                    : val;
            }
        }

        // Cover art.
        if (this.pendingCoverArt) {
            changes['cover_art'] = Array.from(
                new Uint8Array(this.pendingCoverArt.data),
            );
        } else if (this.clearCoverArt) {
            changes['cover_art'] = null;
        }

        return changes;
    }

    private exitEditMode(): void {
        this.editing = false;
        this.editValues = {};
        this.errorMessage = '';
        this.showConfirmation = false;
        this.cleanupPendingCoverArt();
    }

    private async loadMBIDs(filePath: string): Promise<void> {
        try {
            const mbids = await GetTrackMBIDs(filePath);

            if (mbids.recordingMbid || mbids.releaseGroupMbid || mbids.artistMbid) {
                this.trackMBIDs = mbids;
            }
        } catch {
            // Non-critical — MBIDs just won't show.
        }
    }

    private cleanupPendingCoverArt(): void {
        if (this.pendingCoverArt?.previewUrl) {
            URL.revokeObjectURL(
                this.pendingCoverArt.previewUrl,
            );
        }
        this.pendingCoverArt = null;
        this.clearCoverArt = false;
    }

    private resetBatchState(): void {
        this.batchMode = false;
        this.batchTracks = [];
        this.batchFilePaths = [];
        this.batchCoverArtMixed = false;
        this.batchProgress = null;
        this.batchResult = null;
        this.showConfirmation = false;
    }

    private selectCoverArt = async () => {
        try {
            const filePath = await ImageFilePicker();

            if (!filePath) return; // User cancelled.

            const bytes =
                await this.readCoverArtFile(filePath);

            if (!bytes) return;

            // Create preview URL from bytes.
            const buffer = (bytes.buffer as ArrayBuffer).slice(
                bytes.byteOffset,
                bytes.byteOffset + bytes.byteLength,
            );
            const blob = new Blob([buffer]);
            const previewUrl =
                URL.createObjectURL(blob);

            this.cleanupPendingCoverArt();
            this.pendingCoverArt = {
                data: buffer,
                previewUrl,
            };
            this.clearCoverArt = false;
        } catch (err) {
            console.error(
                'Failed to select cover art:',
                err,
            );
        }
    };

    private async readCoverArtFile(
        filePath: string,
    ): Promise<Uint8Array | null> {
        try {
            // ReadFile returns Go []byte which Wails serializes
            // as a base64-encoded string (standard encoding/json
            // behaviour for []byte).
            const result = await ReadFile(filePath);
            const b64 = result as unknown as string;
            const binary = atob(b64);
            const bytes = new Uint8Array(binary.length);

            for (let i = 0; i < binary.length; i++) {
                bytes[i] = binary.charCodeAt(i);
            }

            return bytes;
        } catch (err) {
            console.error(
                'Failed to read cover art file:',
                err,
            );

            return null;
        }
    }

    private removeCoverArt = () => {
        this.cleanupPendingCoverArt();
        this.clearCoverArt = true;
    };

    private getEditValue(
        key: string,
        fallback: string,
    ): string {
        return key in this.editValues
            ? this.editValues[key]!
            : fallback;
    }

    private onEditInput(key: string, e: Event) {
        const input = e.target as HTMLInputElement;

        this.editValues = {
            ...this.editValues,
            [key]: input.value,
        };
    }

    // =================================================================
    // BATCH HELPERS
    // =================================================================

    /** Compute merged field values across all batch tracks. */
    private getMergedFields(): MetadataField[] {
        const tracks = this.batchTracks;

        const extractors: Array<{
            key: string;
            label: string;
            type: 'text' | 'number';
            extract: (t: library.Track) => string;
        }> = [
            {
                key: 'title',
                label: 'Title',
                type: 'text',
                extract: (t) => t.TrackName ?? '',
            },
            {
                key: 'artist',
                label: 'Artist',
                type: 'text',
                extract: (t) => t.ArtistName ?? '',
            },
            {
                key: 'album',
                label: 'Album',
                type: 'text',
                extract: (t) => t.Album ?? '',
            },
            {
                key: 'genre',
                label: 'Genre',
                type: 'text',
                extract: (t) =>
                    (t.Genre ?? []).join(', '),
            },
            {
                key: 'year',
                label: 'Year',
                type: 'number',
                extract: (t) =>
                    t.Year ? String(t.Year) : '',
            },
            {
                key: 'composer',
                label: 'Composer',
                type: 'text',
                extract: (t) => t.Composer ?? '',
            },
            {
                key: 'trackNumber',
                label: 'Track #',
                type: 'number',
                extract: (t) =>
                    t.TrackNumber
                        ? String(t.TrackNumber)
                        : '',
            },
            {
                key: 'discNumber',
                label: 'Disc #',
                type: 'number',
                extract: (t) =>
                    t.DiscNumber
                        ? String(t.DiscNumber)
                        : '',
            },
        ];

        return extractors.map(
            ({ key, label, type, extract }) => {
                const values = tracks.map(extract);
                const unique = new Set(values);
                const allSame = unique.size <= 1;

                return {
                    key,
                    label,
                    value: allSame
                        ? (values[0] ?? '')
                        : '',
                    editable: true,
                    type,
                    mixed: !allSame,
                };
            },
        );
    }

    /** Count distinct values for a field across batch tracks. */
    private countDistinctValues(key: string): number {
        const extractMap: Record<
            string,
            (t: library.Track) => string
        > = {
            title: (t) => t.TrackName ?? '',
            artist: (t) => t.ArtistName ?? '',
            album: (t) => t.Album ?? '',
            genre: (t) => (t.Genre ?? []).join(', '),
            year: (t) =>
                t.Year ? String(t.Year) : '',
            composer: (t) => t.Composer ?? '',
            trackNumber: (t) =>
                t.TrackNumber
                    ? String(t.TrackNumber)
                    : '',
            discNumber: (t) =>
                t.DiscNumber
                    ? String(t.DiscNumber)
                    : '',
        };

        const extract = extractMap[key];

        if (!extract) return 0;

        const unique = new Set(
            this.batchTracks.map(extract),
        );

        return unique.size;
    }

    /** Build human-readable list of changes for confirmation. */
    private getConfirmationSummary(): string[] {
        const summary: string[] = [];

        for (const [key, value] of Object.entries(
            this.editValues,
        )) {
            const label = FIELD_LABELS[key] ?? key;

            if (value === '') {
                summary.push(`Clear ${label}`);
            } else {
                summary.push(
                    `Set ${label} to \u201c${value}\u201d`,
                );
            }
        }

        if (this.pendingCoverArt) {
            summary.push('Set cover art');
        } else if (this.clearCoverArt) {
            summary.push('Remove cover art');
        }

        return summary;
    }

    // =================================================================
    // HELPERS
    // =================================================================

    private fileNameFromPath(filePath: string): string {
        const parts = filePath.split(/[\\/]/);
        const filename =
            parts[parts.length - 1] ?? filePath;

        return filename.replace(/\.[^.]+$/, '');
    }
}

declare global {
    interface HTMLElementTagNameMap {
        'track-details': TrackDetails;
    }
}
