<script lang="ts">
    import { onMount } from 'svelte';
    import { browser } from '$app/environment';
    import { goto } from '$app/navigation';
    import { page } from '$app/stores';
    import {
        listMangas,
        type MangaCard as MangaCardData,
        type MangaSort,
        type SortOrder,
        type MangaStatus
    } from '$lib/api/mangas';
    import { listGenres, type Genre } from '$lib/api/genres';
    import { listTags, type Tag } from '$lib/api/tags';
    import {
        DEFAULT_SORT,
        SORT_FIELD_LABELS,
        defaultOrderFor,
        dirOptions as dirOptionsFor,
        coerceSort,
        coerceOrder,
        sortLabel as composeSortLabel,
        sortUrlParams
    } from '$lib/mangaSort';
    import Chip from '$lib/components/Chip.svelte';
    import MangaCard from '$lib/components/MangaCard.svelte';
    import Pager from '$lib/components/Pager.svelte';
    import SegmentedControl from '$lib/components/SegmentedControl.svelte';
    import Sheet from '$lib/components/Sheet.svelte';
    import Search from '@lucide/svelte/icons/search';
    import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
    import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
    import ArrowRight from '@lucide/svelte/icons/arrow-right';
    import Plus from '@lucide/svelte/icons/plus';

    const PAGE_SIZE = 50;

    let mangas: MangaCardData[] = $state([]);
    let search = $state('');
    let sort = $state<MangaSort>(DEFAULT_SORT);
    let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
    let statusFilter = $state<'' | MangaStatus>('');
    let selectedGenres = $state<Genre[]>([]);
    let selectedTags = $state<Tag[]>([]);
    let allGenres = $state<Genre[]>([]);
    let tagDraft = $state('');
    let tagSuggestions = $state<Tag[]>([]);
    let tagSuggestHighlight = $state(-1);
    let suggestTimer: ReturnType<typeof setTimeout> | null = null;
    // Monotonic counter — discards stale fetch results so a fast typist
    // can't see an earlier query's results overwrite the current one.
    let suggestSeq = 0;
    const tagSuggestListId = 'tag-filter-suggest-list';
    let filtersOpen = $state(false);
    let sortSheetOpen = $state(false);
    let total: number | null = $state(null);
    let loading = $state(true);
    let error: string | null = $state(null);
    let currentPage = $state(1);

    // Tracks whether the viewport sits below the project's single 640px
    // breakpoint. Drives the inline-panel-vs-bottom-sheet split for both
    // filters and sort, so the same form body never renders twice and
    // duplicate testids / focus-trap interference don't occur.
    let isMobileViewport = $state(false);

    const activeFilterCount = $derived(
        (statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
    );

    // Field/direction labelling and defaults live in $lib/mangaSort (unit
    // tested there). Date fields read newest-first; text fields A→Z. The
    // direction stays freely togglable — defaultOrderFor only picks the
    // direction applied when the field changes.
    const dirOptions = $derived(dirOptionsFor(sort));
    const sortLabel = $derived(composeSortLabel(sort, order));

    const totalPages = $derived(
        total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
    );

    // 1-indexed range like "51–100 of 237", clamped to the actual loaded set
    // in case the last page is short.
    const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1);
    const rangeEnd = $derived((currentPage - 1) * PAGE_SIZE + mangas.length);

    async function load() {
        loading = true;
        error = null;
        try {
            const result = await listMangas({
                search: search.trim() || undefined,
                status: statusFilter || undefined,
                genreIds: selectedGenres.map((g) => g.id),
                tagIds: selectedTags.map((t) => t.id),
                sort,
                order,
                limit: PAGE_SIZE,
                offset: (currentPage - 1) * PAGE_SIZE
            });
            mangas = result.items;
            total = result.page.total;
        } catch (e) {
            error = (e as Error).message;
        } finally {
            loading = false;
        }
    }

    function syncUrl() {
        if (!browser) return;
        const params = new URLSearchParams();
        if (search.trim()) params.set('q', search.trim());
        // Defaults are omitted to keep the URL clean; the backend resolves an
        // absent `sort`/`order` the same way, so the omission is unambiguous.
        const sp = sortUrlParams(sort, order);
        if (sp.sort) params.set('sort', sp.sort);
        if (sp.order) params.set('order', sp.order);
        if (statusFilter) params.set('status', statusFilter);
        if (selectedGenres.length)
            params.set('genres', selectedGenres.map((g) => g.id).join(','));
        if (selectedTags.length)
            params.set('tags', selectedTags.map((t) => t.id).join(','));
        if (currentPage > 1) params.set('page', String(currentPage));
        const qs = params.toString();
        const url = qs ? `/?${qs}` : '/';
        goto(url, { replaceState: true, keepFocus: true, noScroll: true });
    }

    // Filter / search / sort changes invalidate the current page — drop back
    // to page 1 so the user isn't stranded on an out-of-range page when the
    // result set shrinks. Direct page navigation calls `goToPage()` instead.
    function resetAndReload() {
        currentPage = 1;
        syncUrl();
        load();
    }

    function goToPage(p: number) {
        if (p === currentPage) return;
        currentPage = p;
        syncUrl();
        load();
        if (browser) window.scrollTo({ top: 0, behavior: 'smooth' });
    }

    async function hydrateFromUrl() {
        // Parse the query and resolve the supplied ids back to full Tag /
        // Genre objects so the chip rows render real labels.
        const url = new URL($page.url);
        search = url.searchParams.get('q') ?? '';
        sort = coerceSort(url.searchParams.get('sort'));
        // Fall back to the field's natural default when `order` is absent or
        // invalid, mirroring how syncUrl omits the default.
        order = coerceOrder(url.searchParams.get('order'), sort);
        const st = url.searchParams.get('status');
        statusFilter = st === 'ongoing' || st === 'completed' ? st : '';
        const genreIds = (url.searchParams.get('genres') ?? '')
            .split(',')
            .filter(Boolean);
        if (genreIds.length) {
            selectedGenres = allGenres.filter((g) => genreIds.includes(g.id));
        }
        const tagIds = (url.searchParams.get('tags') ?? '')
            .split(',')
            .filter(Boolean);
        if (tagIds.length) {
            // listTags doesn't take ids; fetch a generous page and filter.
            // Tag count is small in the near term, so this is fine.
            const tags = await listTags({ limit: 50 });
            selectedTags = tags.filter((t) => tagIds.includes(t.id));
        }
        const pageParam = Number(url.searchParams.get('page') ?? '1');
        currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
        // Auto-expand the inline filters panel on desktop so the user can
        // see why results are filtered. On phones the active-filter chip
        // row carries that signal already, and auto-opening the sheet would
        // hide the catalog behind a scrim on first paint.
        if (
            (statusFilter || selectedGenres.length || selectedTags.length) &&
            browser &&
            !window.matchMedia('(max-width: 640px)').matches
        ) {
            filtersOpen = true;
        }
    }

    async function onSubmit(e: SubmitEvent) {
        e.preventDefault();
        resetAndReload();
    }

    function onSortChange() {
        // Changing the field snaps the direction back to that field's natural
        // default (dates → Newest, text → A→Z); the user can flip afterwards.
        order = defaultOrderFor(sort);
        resetAndReload();
    }

    function pickOrder(next: SortOrder) {
        if (next === order) return;
        order = next;
        resetAndReload();
    }

    function onStatusChange() {
        resetAndReload();
    }

    function toggleGenre(g: Genre) {
        selectedGenres = selectedGenres.some((x) => x.id === g.id)
            ? selectedGenres.filter((x) => x.id !== g.id)
            : [...selectedGenres, g];
        resetAndReload();
    }

    function removeTag(t: Tag) {
        selectedTags = selectedTags.filter((x) => x.id !== t.id);
        resetAndReload();
    }

    function pickTag(t: Tag) {
        if (!selectedTags.some((x) => x.id === t.id)) {
            selectedTags = [...selectedTags, t];
        }
        tagDraft = '';
        tagSuggestions = [];
        tagSuggestHighlight = -1;
        resetAndReload();
    }

    function onTagDraftInput() {
        tagSuggestHighlight = -1;
        if (suggestTimer) clearTimeout(suggestTimer);
        const q = tagDraft.trim();
        if (q.length === 0) {
            tagSuggestions = [];
            suggestSeq++;
            return;
        }
        const seq = ++suggestSeq;
        suggestTimer = setTimeout(async () => {
            try {
                const matched = await listTags({ search: q, limit: 6 });
                if (seq !== suggestSeq) return;
                const chosen = new Set(selectedTags.map((t) => t.id));
                tagSuggestions = matched.filter((m) => !chosen.has(m.id));
            } catch {
                if (seq === suggestSeq) tagSuggestions = [];
            }
        }, 180);
    }

    function onTagFilterKeydown(e: KeyboardEvent) {
        if (e.key === 'ArrowDown' && tagSuggestions.length > 0) {
            e.preventDefault();
            tagSuggestHighlight = (tagSuggestHighlight + 1) % tagSuggestions.length;
        } else if (e.key === 'ArrowUp' && tagSuggestions.length > 0) {
            e.preventDefault();
            tagSuggestHighlight =
                tagSuggestHighlight <= 0
                    ? tagSuggestions.length - 1
                    : tagSuggestHighlight - 1;
        } else if (e.key === 'Enter' && tagSuggestHighlight >= 0) {
            e.preventDefault();
            pickTag(tagSuggestions[tagSuggestHighlight]);
        } else if (e.key === 'Escape') {
            tagSuggestions = [];
            tagSuggestHighlight = -1;
        }
    }

    function clearFilters() {
        statusFilter = '';
        selectedGenres = [];
        selectedTags = [];
        resetAndReload();
    }

    function clearStatus() {
        statusFilter = '';
        resetAndReload();
    }

    function pickSort(next: MangaSort) {
        if (next === sort) return;
        sort = next;
        // Match desktop: snap to the field's natural direction. The sheet
        // stays open so the user can also adjust the direction before
        // dismissing it.
        order = defaultOrderFor(sort);
        resetAndReload();
    }

    onMount(async () => {
        try {
            allGenres = await listGenres();
        } catch {
            // Filter UI still loads with an empty genre list rather than blocking.
        }
        await hydrateFromUrl();
        await load();
    });

    // Track viewport in a separate $effect so the listener cleans up on
    // unmount without entangling with the data-load lifecycle above.
    $effect(() => {
        if (!browser) return;
        const mql = window.matchMedia('(max-width: 640px)');
        isMobileViewport = mql.matches;
        const onChange = (e: MediaQueryListEvent) => {
            isMobileViewport = e.matches;
        };
        mql.addEventListener('change', onChange);
        return () => mql.removeEventListener('change', onChange);
    });
</script>

{#snippet filterFormContent()}
    <div class="filter-group">
        <span class="filter-label">Status</span>
        <div class="status-row">
            <label>
                <input
                    type="radio"
                    name="status"
                    value=""
                    bind:group={statusFilter}
                    onchange={onStatusChange}
                />
                <span>Any</span>
            </label>
            <label>
                <input
                    type="radio"
                    name="status"
                    value="ongoing"
                    bind:group={statusFilter}
                    onchange={onStatusChange}
                />
                <span>Ongoing</span>
            </label>
            <label>
                <input
                    type="radio"
                    name="status"
                    value="completed"
                    bind:group={statusFilter}
                    onchange={onStatusChange}
                />
                <span>Completed</span>
            </label>
        </div>
    </div>

    <div class="filter-group">
        <span class="filter-label">Genres (all must match)</span>
        <div class="filter-chip-row">
            {#each allGenres as g (g.id)}
                {@const on = selectedGenres.some((x) => x.id === g.id)}
                <button
                    type="button"
                    class="genre-pill"
                    class:active={on}
                    onclick={() => toggleGenre(g)}
                    data-testid={`genre-filter-${g.name}`}
                >
                    {g.name}
                </button>
            {/each}
        </div>
    </div>

    <div class="filter-group">
        <span class="filter-label">Tags (all must match)</span>
        {#if selectedTags.length > 0}
            <div class="filter-chip-row">
                {#each selectedTags as t (t.id)}
                    <Chip
                        label={t.name}
                        variant="primary"
                        onRemove={() => removeTag(t)}
                        removeLabel={`Remove tag ${t.name}`}
                        testid={`tag-filter-chip-${t.name}`}
                    />
                {/each}
            </div>
        {/if}
        <div class="tag-search">
            <input
                type="text"
                role="combobox"
                bind:value={tagDraft}
                oninput={onTagDraftInput}
                onkeydown={onTagFilterKeydown}
                placeholder="Type to find a tag"
                maxlength="64"
                aria-label="Find a tag"
                aria-controls={tagSuggestListId}
                aria-expanded={tagSuggestions.length > 0}
                aria-autocomplete="list"
                aria-activedescendant={tagSuggestHighlight >= 0
                    ? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
                    : undefined}
                data-testid="tag-filter-input"
            />
            {#if tagSuggestions.length > 0}
                <ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
                    {#each tagSuggestions as s, i (s.id)}
                        <li
                            id={`${tagSuggestListId}-opt-${i}`}
                            role="option"
                            aria-selected={i === tagSuggestHighlight}
                            class:active={i === tagSuggestHighlight}
                        >
                            <button
                                type="button"
                                tabindex="-1"
                                onmouseenter={() => (tagSuggestHighlight = i)}
                                onclick={() => pickTag(s)}
                                data-testid={`tag-filter-suggestion-${s.name}`}
                            >
                                <Plus size={12} aria-hidden="true" />
                                {s.name}
                            </button>
                        </li>
                    {/each}
                </ul>
            {/if}
        </div>
    </div>

    {#if activeFilterCount > 0}
        <button type="button" class="clear" onclick={clearFilters}>
            Clear filters
        </button>
    {/if}
{/snippet}

<div class="heading-row">
    <h1>Mangas</h1>
    <!--
        Secondary entry point into the per-page search at /search,
        which is headed "Page search". Label matches the destination's
        title so the two read as one feature. It filters the user's
        tagged pages and pivots between Pages / Chapters / Mangas;
        title-search above stays the primary action. The name is also
        forward-compatible with the planned OCR text search (which
        searches page content, not just tags).
    -->
    <a class="alt-search" href="/search" data-testid="nav-page-search">
        <span>Page search</span>
        <ArrowRight size={14} aria-hidden="true" />
    </a>
</div>

<form
    onsubmit={onSubmit}
    action="javascript:void(0)"
    class="controls"
>
    <div class="search-row">
        <input
            class="search"
            type="search"
            bind:value={search}
            placeholder="Search by title or author"
            data-testid="search-input"
        />
        <button class="icon-btn primary" type="submit" aria-label="Search" title="Search">
            <Search size={18} aria-hidden="true" />
        </button>
        <button
            type="button"
            class="filters-toggle"
            class:active={filtersOpen}
            onclick={() => (filtersOpen = !filtersOpen)}
            aria-expanded={filtersOpen}
            aria-controls="filters-panel"
            data-testid="filters-toggle"
        >
            <SlidersHorizontal size={16} aria-hidden="true" />
            <span>Filters</span>
            {#if activeFilterCount > 0}
                <span class="filter-count">{activeFilterCount}</span>
            {/if}
        </button>
        <button
            type="button"
            class="sort-chip mobile-only"
            onclick={() => (sortSheetOpen = true)}
            data-testid="sort-chip"
        >
            <ArrowUpDown size={16} aria-hidden="true" />
            <span>{sortLabel}</span>
        </button>
    </div>

    {#if activeFilterCount > 0}
        <div class="active-filters" data-testid="active-filters">
            {#if statusFilter}
                <Chip
                    label={statusFilter === 'ongoing' ? 'Ongoing' : 'Completed'}
                    variant="primary"
                    onRemove={clearStatus}
                    removeLabel="Clear status filter"
                    testid="active-filter-status"
                />
            {/if}
            {#each selectedGenres as g (g.id)}
                <Chip
                    label={g.name}
                    variant="primary"
                    onRemove={() => toggleGenre(g)}
                    removeLabel={`Remove genre ${g.name}`}
                    testid={`active-filter-genre-${g.name}`}
                />
            {/each}
            {#each selectedTags as t (t.id)}
                <Chip
                    label={t.name}
                    variant="primary"
                    onRemove={() => removeTag(t)}
                    removeLabel={`Remove tag ${t.name}`}
                    testid={`active-filter-tag-${t.name}`}
                />
            {/each}
        </div>
    {/if}

    {#if filtersOpen && !isMobileViewport}
        <div class="filters-panel" id="filters-panel" data-testid="filters-panel">
            {@render filterFormContent()}
        </div>
    {/if}

    <div class="config-row desktop-only">
        <label class="sort">
            <span>Sort</span>
            <select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
                <option value="updated">Last updated</option>
                <option value="created">Date added</option>
                <option value="title">Title</option>
                <option value="author">Author</option>
            </select>
        </label>
        <div class="sort">
            <span>Direction</span>
            <SegmentedControl
                options={dirOptions}
                value={order}
                onchange={pickOrder}
                ariaLabel="Sort direction"
                testid="sort-order"
            />
        </div>
    </div>
</form>

<Sheet
    open={filtersOpen && isMobileViewport}
    title="Filter"
    onClose={() => (filtersOpen = false)}
    testid="filter-sheet"
>
    {@render filterFormContent()}
</Sheet>

<Sheet
    open={sortSheetOpen && isMobileViewport}
    title="Sort"
    onClose={() => (sortSheetOpen = false)}
    testid="sort-sheet"
>
    <p id="sort-field-heading" class="sort-section-label">Sort by</p>
    <div class="sort-options" role="radiogroup" aria-labelledby="sort-field-heading">
        {#each Object.entries(SORT_FIELD_LABELS) as [value, label] (value)}
            <label>
                <input
                    type="radio"
                    name="sort-mobile"
                    {value}
                    aria-label={label}
                    checked={sort === value}
                    onchange={() => pickSort(value as MangaSort)}
                />
                <!-- aria-label duplicates the visible span text on purpose so
                     the e2e getByRole('radio', { name }) lookup is stable. -->
                <span>{label}</span>
            </label>
        {/each}
    </div>
    <div class="sort-direction">
        <p class="sort-section-label">Direction</p>
        <SegmentedControl
            options={dirOptions}
            value={order}
            onchange={pickOrder}
            ariaLabel="Sort direction"
            testid="sort-order-mobile"
        />
    </div>
    {#snippet footer()}
        <button
            type="button"
            class="primary"
            onclick={() => (sortSheetOpen = false)}
            data-testid="sort-done"
        >
            Done
        </button>
    {/snippet}
</Sheet>

{#if loading}
    <p class="status" data-testid="loading">Loading…</p>
{:else if error}
    <p class="error" data-testid="error" role="alert">{error}</p>
{:else if mangas.length === 0}
    <p class="status" data-testid="empty">No mangas yet. <a href="/upload">Upload one</a>.</p>
{:else}
    {#if total !== null}
        <p class="count" data-testid="manga-total">
            Showing {rangeStart}–{rangeEnd} of {total}
        </p>
    {/if}
    <ul class="manga-grid" data-testid="manga-list">
        {#each mangas as m (m.id)}
            <MangaCard manga={m} authors={m.authors} genres={m.genres} />
        {/each}
    </ul>
    <Pager
        page={currentPage}
        {totalPages}
        onChange={goToPage}
        testid="manga-pager"
    />
{/if}

<style>
    .heading-row {
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: var(--space-3);
        flex-wrap: wrap;
    }

    .heading-row h1 {
        margin-bottom: 0;
    }

    .alt-search {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        color: var(--text-muted);
        font-size: var(--font-sm);
        text-decoration: none;
        white-space: nowrap;
    }

    .alt-search:hover {
        color: var(--primary);
    }

    .controls {
        display: flex;
        flex-direction: column;
        gap: var(--space-3);
        margin-bottom: var(--space-4);
        margin-top: var(--space-3);
    }

    .search-row {
        display: flex;
        gap: var(--space-2);
        align-items: center;
        flex-wrap: wrap;
    }

    .search {
        flex: 1;
        min-width: 0;
        max-width: 28rem;
    }

    @media (max-width: 640px) {
        /* Give the search input its own row so the Filter + Sort chips
           can't squeeze it down to a few characters. The chips wrap to
           a second row naturally; the icon-btn submit stays paired with
           the input on row one. */
        .search {
            flex-basis: calc(100% - 36px - var(--space-2));
            max-width: none;
        }
    }

    .filters-toggle {
        display: inline-flex;
        align-items: center;
        gap: var(--space-2);
        padding: 0 var(--space-3);
        height: 36px;
        background: var(--surface);
        border: 1px solid var(--border-strong);
        color: var(--text);
        cursor: pointer;
    }

    .filters-toggle:hover,
    .filters-toggle.active {
        background: var(--surface-elevated);
        border-color: var(--primary);
    }

    .filter-count {
        background: var(--primary);
        color: var(--primary-contrast);
        border-radius: var(--radius-pill);
        font-size: var(--font-xs);
        padding: 0 6px;
        min-width: 18px;
        text-align: center;
    }

    .sort-chip {
        display: inline-flex;
        align-items: center;
        gap: var(--space-2);
        padding: 0 var(--space-3);
        height: 36px;
        background: var(--surface);
        border: 1px solid var(--border-strong);
        color: var(--text);
        cursor: pointer;
    }

    .sort-chip:hover {
        background: var(--surface-elevated);
        border-color: var(--primary);
    }

    .active-filters {
        display: flex;
        flex-wrap: wrap;
        gap: var(--space-2);
    }

    .filters-panel {
        display: flex;
        flex-direction: column;
        gap: var(--space-3);
        padding: var(--space-3);
        background: var(--surface);
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
    }

    .filter-group {
        display: flex;
        flex-direction: column;
        gap: var(--space-2);
    }

    .filter-label {
        color: var(--text-muted);
        font-size: var(--font-sm);
        font-weight: var(--weight-medium);
    }

    .status-row {
        display: flex;
        gap: var(--space-3);
        flex-wrap: wrap;
    }

    .status-row label {
        display: inline-flex;
        align-items: center;
        gap: var(--space-1);
        color: var(--text);
        font-size: var(--font-sm);
        cursor: pointer;
    }

    .filter-chip-row {
        display: flex;
        flex-wrap: wrap;
        gap: var(--space-2);
    }

    .genre-pill {
        display: inline-flex;
        align-items: center;
        padding: 4px var(--space-2);
        background: var(--surface-elevated);
        border: 1px solid var(--border);
        color: var(--text);
        border-radius: var(--radius-pill);
        font-size: var(--font-xs);
        cursor: pointer;
    }

    .genre-pill:hover {
        border-color: var(--primary);
    }

    .genre-pill.active {
        background: var(--primary-soft-bg);
        border-color: var(--primary);
        color: var(--primary);
    }

    .tag-search {
        position: relative;
        max-width: 20rem;
    }

    .tag-search input {
        width: 100%;
    }

    .tag-suggestions {
        position: absolute;
        top: 100%;
        left: 0;
        right: 0;
        margin: var(--space-1) 0 0;
        padding: var(--space-1) 0;
        list-style: none;
        background: var(--surface);
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
        z-index: var(--z-dropdown);
    }

    .tag-suggestions button {
        width: 100%;
        background: transparent;
        border: 0;
        text-align: left;
        display: inline-flex;
        align-items: center;
        gap: var(--space-2);
        padding: var(--space-1) var(--space-3);
        color: var(--text);
        cursor: pointer;
        font-size: var(--font-sm);
    }

    .tag-suggestions li.active button,
    .tag-suggestions button:hover {
        background: var(--primary-soft-bg);
    }

    .clear {
        align-self: flex-start;
        background: transparent;
        border: 1px solid var(--border);
        color: var(--text-muted);
        font-size: var(--font-sm);
    }

    .config-row {
        display: flex;
        flex-wrap: wrap;
        gap: var(--space-3);
        align-items: center;
    }

    .sort {
        display: flex;
        align-items: center;
        gap: var(--space-2);
        color: var(--text-muted);
        font-size: var(--font-sm);
    }

    .sort select {
        width: auto;
    }

    .sort-options {
        display: flex;
        flex-direction: column;
        gap: var(--space-3);
    }

    .sort-options label {
        display: flex;
        align-items: center;
        gap: var(--space-3);
        padding: var(--space-2) 0;
        cursor: pointer;
    }

    .sort-direction {
        margin-top: var(--space-3);
        padding-top: var(--space-3);
        border-top: 1px solid var(--border);
    }

    /* Section headings inside the mobile sort sheet ("Sort by" / "Direction")
       so each group is visibly labelled, mirroring the desktop inline labels. */
    .sort-section-label {
        margin: 0 0 var(--space-2);
        font-size: var(--font-sm);
        font-weight: var(--weight-medium);
        color: var(--text-muted);
    }

    .primary {
        border: 1px solid var(--primary);
        background: var(--primary);
        color: var(--primary-contrast);
    }

    .primary:hover {
        background: var(--primary-hover);
        border-color: var(--primary-hover);
    }

    .icon-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 36px;
        height: 36px;
        padding: 0;
    }

    .icon-btn.primary {
        background: var(--primary);
        color: var(--primary-contrast);
        border: 1px solid var(--primary);
    }

    .icon-btn.primary:hover:not(:disabled) {
        background: var(--primary-hover);
        border-color: var(--primary-hover);
    }

    .status {
        color: var(--text-muted);
    }

    .error {
        color: var(--danger);
    }

    .count {
        color: var(--text-muted);
        font-size: var(--font-sm);
        margin: var(--space-2) 0;
    }

    /* `.manga-grid` is a shared global (see lib/styles/tokens.css), including
       its four-per-row phone layout. */

    /* Mobile-only helpers. Defaults flip below the 640px breakpoint
       so the same chrome doesn't render twice. */
    .mobile-only {
        display: none;
    }

    @media (max-width: 640px) {
        .desktop-only {
            display: none;
        }

        .mobile-only {
            display: inline-flex;
        }

        .search {
            /* Catalog search should fill the available width on phones —
               the 28rem cap is desktop-only. */
            max-width: none;
        }
    }
</style>
