# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2026
from __python__ import hash_literals

from elementmaker import E
from gettext import gettext as _

from ajax import ajax
from book_list.globals import get_session_data
from book_list.item_list import create_item, create_item_list
from book_list.library_data import browse_field_for, url_books_query, cover_url, current_library_id
from book_list.router import back, update_window_title
from book_list.theme import get_color
from book_list.top_bar import create_top_bar
from book_list.ui import set_panel_handler, show_panel
from dom import add_extra_css, build_rule, clear, svgicon
from session import get_interface_data
from utils import parse_url_params
from widgets import create_pager_widget, create_button


def escape_for_search(x):
    # Minimal escaping for calibre search syntax
    return (x or '').replace('\\', '\\\\').replace('"', '\\"')


def sync_cs_list_row_heights(list_root):
    """Make all list rows the same height (max of siblings) so item-subtitle stacks don't vary row height."""
    if not list_root:
        return
    ul = list_root.querySelector('ul')
    if not ul:
        return
    disp = (window.getComputedStyle(ul).display or '') + ''
    if disp.indexOf('flex') == -1:
        return
    ch = ul.children
    lis = []
    for i in range(ch.length):
        node = ch[i]
        if node.tagName.toLowerCase() is 'li':
            lis.push(node)
    if lis.length is 0:
        return
    for i in range(lis.length):
        lis[i].style.minHeight = ''
    max_h = 0
    for i in range(lis.length):
        h = lis[i].offsetHeight
        if h > max_h:
            max_h = h
    if max_h <= 0:
        return
    mh = str(max_h) + 'px'
    for i in range(lis.length):
        lis[i].style.minHeight = mh


def title_for_field(field):
    info = browse_field_for(field)
    return info?.name or field or ''


def subtitle_for_field(field):
    return browse_field_for(field)?.description or ''


def rating_search_query_from_display_name(label):
    """Map tag-browser rating label (e.g. ★★★) to calibre search rating:N with N in 1..5."""
    s = (label or '') + ''
    n = 0
    for i in range(s.length):
        if s[i] is '★':
            n += 1
    if n >= 1 and n <= 5:
        return n
    try:
        v = int(Math.round(parseFloat(s)))
        if v >= 1 and v <= 5:
            return v
    except Exception:
        pass
    return 1


def make_search(field, label):
    label = label or ''
    field = field or ''
    if not field:
        return {'search': ''}
    if field is 'rating':
        n = rating_search_query_from_display_name(label)
        return {'search': 'rating:' + str(n)}
    return {'search': f'{field}:"={escape_for_search(label)}"'}


def stars_for_rating(label):
    try:
        n = int(label)
    except Exception:
        n = 0
    n = max(1, min(5, n))
    return '★' * n


def parse_rating_from_api(avg_rating):
    if avg_rating is None or str(avg_rating).strip() == '':
        return None
    try:
        r = float(avg_rating)
    except Exception:
        return None
    if not isFinite(r):
        return None
    r = clamp(r, 0, 5)
    # Keep one decimal as requested for display/tooltip.
    text = '{:.1f}'.format(r)
    return {'value': r, 'text': text}


def create_fractional_rating_stars(avg_rating):
    info = parse_rating_from_api(avg_rating)
    if not info:
        return None
    percent = (info.value / 5.0) * 100.0
    root = E.span(class_='cs-rating-stars', title=info.text)
    root.appendChild(E.span('★★★★★', class_='cs-rating-stars-base'))
    fill = E.span('★★★★★', class_='cs-rating-stars-fill')
    fill.style.width = '{:.1f}%'.format(percent)
    root.appendChild(fill)
    return root


def tags_cache_key():
    q = parse_url_params()
    sd = get_session_data()
    return '|'.join([
        current_library_id() or '',
        q.vl or '',
        q.search or '',
        sd.get('sort_tags_by') + '',
        sd.get('partition_method') + '',
        sd.get('collapse_at') + '',
        sd.get('dont_collapse') + '',
        sd.get('hide_empty_categories') + '',
    ])


cached_names = {}
series_cover_cache = {}
series_cover_inflight = {}
SERIES_COVER_CLASSES = ['covers-0', 'covers-1', 'covers-2', 'covers-3', 'covers-4', 'covers-5']


def series_cover_key(library_id, series_name):
    return JSON.stringify([(library_id or '') + '', (series_name or '') + ''])


def series_stack_cover_count(total):
    try:
        n = int(total)
    except Exception:
        return 0
    if n < 1:
        return 0
    n = min(5, n)
    if n > 1 and n % 2 == 0:
        n -= 1
    return n


def set_series_stack_cover_count(div, total):
    count = series_stack_cover_count(total)
    for cls in SERIES_COVER_CLASSES:
        div.classList.remove(cls)
    div.classList.add('covers-' + str(count))
    return count


def series_stack_cover_urls(urls):
    actual = []
    for u in urls or []:
        if u:
            actual.push(u)
    return actual.slice(0, series_stack_cover_count(actual.length))


def render_series_stack_covers(div, urls):
    actual = series_stack_cover_urls(urls)
    while div.firstChild:
        div.removeChild(div.firstChild)
    count = set_series_stack_cover_count(div, actual.length)
    for i, u in enumerate(actual):
        img = E.img(src=u, class_='cover-img cover-' + str(i))
        div.appendChild(img)
    return count

add_extra_css(def ():
    sel = '.cs-nav-list '
    style = ''
    # Unified nav subtitle (so "Browse all ..." looks consistent across fields)
    style += build_rule(sel + '.cs-nav-subtitle',
        margin='0',
        padding='0.9rem 1rem 0 1rem',
        opacity='0.8'
    )
    for fld in ('authors', 'publisher', 'series', 'formats'):
        style += build_rule(sel + f'[data-field="{fld}"] .generic-items-list > p',
            margin='0',
            padding='0.9rem 1rem 0 1rem',
            border='none',
            background='none',
            border_radius='0',
            font_style='normal',
            opacity='0.8'
        )
    # Tags: render as bubbles with incremental "show more"
    style += build_rule(sel + '.cs-tag-bubbles',
        padding='0.75rem 1rem',
        display='flex',
        flex_wrap='wrap',
        gap='0.55rem 0.55rem',
        align_content='flex-start'
    )
    style += build_rule(sel + 'a.cs-tag-bubble',
        display='inline-flex',
        align_items='center',
        max_width='100%',
        padding='0.45rem 0.75rem',
        border_radius='999px',
        border='var(--calibre-border-style-subtle)',
        background_color=get_color('window-background'),
        color=get_color('window-foreground'),
        text_decoration='none',
        cursor='pointer',
        user_select='none',
        white_space='nowrap',
        overflow='hidden',
        text_overflow='ellipsis',
    )
    style += build_rule(sel + 'a.cs-tag-bubble:hover', background_color='var(--calibre-color-primary-light)')
    style += build_rule(sel + 'a.cs-tag-bubble:active', transform='scale(0.99)')
    style += build_rule(sel + 'a.cs-tag-bubble:focus', outline='none', box_shadow='var(--calibre-focus-ring)')
    style += build_rule(sel + 'a.cs-tag-bubble .cs-tag-meta', font_size='0.85em', opacity='0.78')
    style += build_rule(sel + '.cs-show-more-wrap',
        padding='0.25rem 1rem 1rem 1rem',
        display='flex',
        justify_content='center'
    )
    style += build_rule(sel + 'a.cs-show-more',
        display='inline-flex',
        align_items='center',
        gap='0.35rem',
        padding='0.55rem 0.9rem',
        border_radius='999px',
        border='var(--calibre-border-style-subtle)',
        background_color='rgba(0,0,0,0.02)',
        color=get_color('window-foreground'),
        text_decoration='none',
        cursor='pointer',
        user_select='none'
    )
    style += build_rule(sel + 'a.cs-show-more:hover', background_color='var(--calibre-color-primary-light)')
    # Fractional rating stars (5 base stars + themed fill overlay)
    style += build_rule(sel + '.cs-rating-stars',
        position='relative',
        display='inline-block',
        line_height='1',
        letter_spacing='0.06em',
        white_space='nowrap',
        user_select='none'
    )
    style += build_rule(sel + '.cs-rating-stars-base',
        color='rgba(0,0,0,0.18)'
    )
    style += build_rule(sel + '.cs-rating-stars-fill',
        position='absolute',
        left='0',
        top='0',
        overflow='hidden',
        white_space='nowrap',
        color='var(--calibre-color-primary)',
        pointer_events='none'
    )
    # Toggle (segmented) control
    style += build_rule(sel + '.cs-toggle',
        display='inline-flex',
        align_items='stretch',
        border='var(--calibre-border-style-subtle)',
        border_radius='999px',
        overflow='hidden',
    )
    style += build_rule(sel + 'a.cs-toggle-btn',
        display='inline-flex', align_items='center', gap='0.35rem',
        padding='0.45rem 0.7rem',
        color=get_color('window-foreground'),
        cursor='pointer', user_select='none', text_decoration='none',
        border_right='var(--calibre-border-style-subtle)'
    )
    style += build_rule(sel + 'a.cs-toggle-btn:last-child', border_right='none')
    style += build_rule(sel + 'a.cs-toggle-btn[aria-pressed="true"]',
        background_color='var(--calibre-color-primary-light-2)',
        font_weight='700'
    )
    style += build_rule(sel + 'a.cs-toggle-btn:hover', background_color='var(--calibre-color-primary-light)')
    # Series stack-cover mode: isolated classes to avoid conflicts with list mode selectors.
    style += build_rule(
        '.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-row',
        display='flex',
        flex_direction='column',
        align_items='center',
        justify_content='center',
        gap='0.55rem',
        min_width='0',
        width='100%',
        flex='1 1 auto'
    )
    style += build_rule(
        '.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers',
        position='relative',
        width='min(100%, var(--cs-series-stack-width, 220px))',
        height='var(--cs-series-stack-height, 204px)',
        box_sizing='border-box',
        margin_left='auto',
        margin_right='auto',
        flex='0 0 auto',
        overflow='visible',
        border_radius='14px'
    )
    style += build_rule('.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers img.cover-img',
        position='absolute',
        left='50%',
        bottom='12px',
        width='112px',
        height='168px',
        object_fit='cover',
        border_radius='12px',
        border='var(--calibre-border-style-subtle)',
        background_color='rgba(0,0,0,0.05)',
        box_shadow='0 12px 32px rgba(0,0,0,0.16)',
        transition='transform 200ms ease, box-shadow 200ms ease'
    )
    style += build_rule('.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers img.cover-img:hover',
        box_shadow='0 26px 64px rgba(0,0,0,0.22)'
    )
    # Per-count sizing/positioning: odd stacks only (1 / 3 / 5).
    style += r'''
.cs-nav-list .cs-series-stack-covers.covers-0 {
  display: none;
}

.cs-nav-list .cs-series-stack-covers.covers-1 {
  --cs-series-stack-width: 132px;
  --cs-series-stack-height: 188px;
}
.cs-nav-list .cs-series-stack-covers.covers-1 .cover-0 {
  transform: translate(-50%, 0) rotate(0deg) scale(1);
  z-index: 3;
}

.cs-nav-list .cs-series-stack-covers.covers-3 {
  --cs-series-stack-width: 192px;
  --cs-series-stack-height: 200px;
}
.cs-nav-list .cs-series-stack-covers.covers-3 .cover-0 {
  transform: translate(calc(-50% - 28px), -12px) rotate(-6deg) scale(0.90);
  z-index: 1;
}
.cs-nav-list .cs-series-stack-covers.covers-3 .cover-1 {
  transform: translate(calc(-50% + 28px), -12px) rotate(6deg) scale(0.90);
  z-index: 1;
}
.cs-nav-list .cs-series-stack-covers.covers-3 .cover-2 {
  transform: translate(-50%, 0) rotate(0deg) scale(1);
  z-index: 3;
}

.cs-nav-list .cs-series-stack-covers.covers-5 {
  --cs-series-stack-width: 220px;
  --cs-series-stack-height: 204px;
}
.cs-nav-list .cs-series-stack-covers.covers-5 .cover-0 {
  transform: translate(calc(-50% - 42px), -14px) rotate(-7deg) scale(0.84);
  z-index: 1;
}
.cs-nav-list .cs-series-stack-covers.covers-5 .cover-1 {
  transform: translate(calc(-50% + 42px), -14px) rotate(7deg) scale(0.84);
  z-index: 1;
}
.cs-nav-list .cs-series-stack-covers.covers-5 .cover-2 {
  transform: translate(calc(-50% - 20px), -6px) rotate(-3.5deg) scale(0.93);
  z-index: 2;
}
.cs-nav-list .cs-series-stack-covers.covers-5 .cover-3 {
  transform: translate(calc(-50% + 20px), -6px) rotate(3.5deg) scale(0.93);
  z-index: 2;
}
.cs-nav-list .cs-series-stack-covers.covers-5 .cover-4 {
  transform: translate(-50%, 0) rotate(0deg) scale(1);
  z-index: 3;
}
'''
    # Loading overlay (prevents flash/flicker on updates)
    style += build_rule(sel + '.cs-list-wrap', position='relative', flex='1 1 auto', min_height='0')
    style += build_rule(sel + '.cs-loading-overlay',
        position='absolute', left='0', top='0', right='0', bottom='0',
        display='flex', align_items='center', justify_content='center',
        background_color=get_color('window-background'),
        opacity='0',
        pointer_events='none',
        transition='opacity 160ms ease'
    )
    style += build_rule(sel + '.cs-list-content',
        transition='opacity 160ms ease',
        will_change='opacity'
    )
    # Nav browse list: rating/count column absolutely positioned right. Row uses flex + align center so
    # item-title-body stays vertically centered when subtitle stack is taller (after li min-height sync).
    nav_flex_li = (
        '.cs-nav-list:not([data-field="series"]) .cs-list-content.generic-items-list > ul > li:not(:has(> div:nth-child(2))), ' +
        '.cs-nav-list[data-field="series"][data-series-display="list"] .cs-list-content.generic-items-list > ul > li:not(:has(> div:nth-child(2)))'
    )
    style += build_rule(nav_flex_li,
        display='flex',
        flex_direction='column',
    )
    nav_flex_li_cell = (
        '.cs-nav-list:not([data-field="series"]) .cs-list-content.generic-items-list > ul > li:not(:has(> div:nth-child(2))) > div:nth-child(1), '
        '.cs-nav-list[data-field="series"][data-series-display="list"]'
        ' .cs-list-content.generic-items-list > ul > li:not(:has(> div:nth-child(2))) > div:nth-child(1)'
    )
    style += build_rule(nav_flex_li_cell,
        flex_grow='1',
        flex_shrink='0',
        width='100%',
        min_height='0',
        box_sizing='border-box',
    )
    style += build_rule(sel + '.cs-list-content.generic-items-list .item-first-row:not(:has(.item-subtitle-nav))',
        display='flex',
    )
    style += build_rule(sel + '.cs-list-content.generic-items-list .item-first-row:has(.item-subtitle-nav)',
        display='flex',
        align_items='center',
    )
    style += build_rule(sel + '.cs-list-content.generic-items-list .item-first-row:has(.item-subtitle-nav) .item-title-body',
        padding_right='9.5rem',
        box_sizing='border-box',
    )
    style += build_rule(sel + '.cs-list-content.generic-items-list .item-subtitle.item-subtitle-nav',
        position='absolute',
        right='0.95rem',
        top='0',
        bottom='0',
        margin_top='auto',
        margin_bottom='auto',
        height='fit-content',
        max_height='100%',
        width='max-content',
        max_width='min(9rem, calc(100% - 1rem))',
        margin_left='0',
        text_align='right',
        display='flex',
        flex_direction='column',
        align_items='flex_end',
        justify_content='center',
        gap='0.1rem',
        line_height='1.15',
        padding_top='0',
        overflow='hidden',
        box_sizing='border-box',
    )
    # Series card grid: same row has subtitle-nav but layout must stay columnar (not browse-row center).
    style += build_rule(sel + '.cs-loading .cs-loading-overlay', opacity='1')
    style += build_rule(sel + '.cs-loading .cs-list-content', opacity='0.45')
    style += r'''
@keyframes cs_spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.cs-nav-list .cs-loading-overlay svg { animation: cs_spin 1.2s linear infinite; opacity: 0.85; }
'''
    # Series panel as grid (cards): stretch items in each row to the row's max height so tiles align.
    style += build_rule('.cs-nav-list[data-field="series"] .generic-items-list ul',
        display='grid',
        grid_template_columns='repeat(auto-fill, minmax(220px, 1fr))',
        gap='0.75rem',
        align_items='stretch',
    )
    style += build_rule('.cs-nav-list[data-field="series"] .generic-items-list li',
        display='flex',
        flex_direction='column',
        align_items='stretch',
        min_height='0',
        height='100%',
    )
    style += build_rule('.cs-nav-list[data-field="series"] .generic-items-list li > div:nth-child(1)',
        flex='1 1 auto',
        width='100%',
        min_height='0',
        display='flex',
        flex_direction='column',
        justify_content='flex-start',
        align_items='center'
    )
    style += build_rule('.cs-nav-list[data-field="series"] .generic-items-list li:hover',
        transform='translateY(-1px)'
    )
    style += build_rule('.cs-nav-list[data-field="series"] .generic-items-list li > div:nth-child(1)',
        padding='0.95rem 0.95rem 0.9rem 0.95rem'
    )
    # Keep series stack fully inside the card; no overflow outside list item.
    style += build_rule('.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .generic-items-list li.cs-series-stack-item',
        overflow='hidden')
    style += build_rule('.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .generic-items-list .cs-series-stack-item-row',
        flex_direction='column',
        align_items='stretch')
    # Disable hover "lift" animation for series items (prevents cover zoom feel)
    style += build_rule('.generic-items-list li.cs-series-stack-item:hover', transform='none')
    style += build_rule('.generic-items-list li.cs-series-stack-item:active', transform='none')
    # Series list mode: same layout as authors/publisher (no grid, no covers)
    style += build_rule('.cs-nav-list[data-field="series"][data-series-display="list"] .generic-items-list ul',
        display='flex',
        flex_direction='column',
        gap='0.5rem'
    )
    style += build_rule('.cs-nav-list[data-field="series"][data-series-display="list"] .generic-items-list li',
        aspect_ratio='auto',
        display='block'
    )
    style += build_rule('.cs-nav-list[data-field="series"][data-series-display="list"] .generic-items-list li > div:nth-child(1)',
        display='block',
        position='relative',
        padding='0.85rem 0.95rem'
    )
    style += build_rule('.cs-nav-list[data-field="series"][data-series-display="list"] .item-title-body',
        text_align='left'
    )
    # Series stack mode: same layout as authors/publisher (no grid, no covers)
    style += build_rule('.cs-nav-list[data-field="series"]:not([data-series-display="list"]) .generic-items-list li.cs-series-stack-item .item-title-body',
        padding_right='0',
    )
    style += build_rule(
        '.cs-nav-list[data-field="series"]:not([data-series-display="list"])'
        ' .generic-items-list li.cs-series-stack-item .item-subtitle.item-subtitle-nav',
       position='relative',
       right='0',
       top='0',
       bottom='0',
       margin_top='auto',
       margin_bottom='auto',
       height='fit-content',
       max_height='100%',
       width='max-content',
    )
    style += build_rule(sel + 'input.cs-filter',
        flex='1 1 auto',
        width='0',
        min_width='9em'
    )
    style += r'''
@media (max-width: 420px) {
  .cs-nav-list[data-field="series"] .generic-items-list ul {
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 0.55rem;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers {
    --cs-series-stack-width: 190px;
    --cs-series-stack-height: 168px;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers.covers-1 {
    --cs-series-stack-width: 108px;
    --cs-series-stack-height: 150px;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers.covers-3 {
    --cs-series-stack-width: 160px;
    --cs-series-stack-height: 162px;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers img.cover-img {
    width: 88px;
    height: 132px;
    bottom: 10px;
  }
}
@media (max-width: 360px) {
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers {
    --cs-series-stack-width: 170px;
    --cs-series-stack-height: 152px;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers.covers-1 {
    --cs-series-stack-width: 98px;
    --cs-series-stack-height: 137px;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers.covers-3 {
    --cs-series-stack-width: 148px;
    --cs-series-stack-height: 149px;
  }
  .cs-nav-list[data-field="series"]:not([data-series-display="list"]) .cs-series-stack-covers img.cover-img {
    width: 80px;
    height: 120px;
    bottom: 9px;
  }
}
'''
    return style
)


def parse_int(x, default_value):
    try:
        x = int(x)
    except Exception:
        x = default_value
    return x


def clamp(n, lo, hi):
    return max(lo, min(hi, n))


def update_nav_list_url(field, offset, num, query_text=None, replace=True, g=None):
    q = {'field': field, 'offset': str(offset), 'num': str(num)}
    if query_text:
        q.q = query_text
    g = g or parse_url_params().g
    if g:
        q.g = g
    show_panel('nav_list', q, replace=replace)


def create_nav_list_panel(container_id):
    interface_data = get_interface_data()
    lid = url_books_query().library_id
    title = interface_data.library_map[lid]
    update_window_title(title)
    container = document.getElementById(container_id)
    clear(container)
    q = parse_url_params()
    field = q.field or ''
    field_info = browse_field_for(field) or {'kind': 'category'}
    num = clamp(parse_int(q.num, 15), 5, 100)
    offset = max(0, parse_int(q.offset, 0))
    query_text = (q.q or '').toString()
    group_mode = (q.g or 'y').toString()
    if field_info.kind is 'date':
        query_text = ''
    # Tags panel: render all tags progressively on one page (no paging)
    visible_tags = 50
    title = title_for_field(field)
    create_top_bar(container, title=title, action=back, icon='back')
    container.appendChild(E.div())
    body = container.lastChild
    body.classList.add('cs-nav-list')
    body.dataset.field = field
    if field is 'series':
        sd = get_session_data()
        series_mode = sd.get('series_nav_display_mode', 'stack') or 'stack'
        body.dataset.seriesDisplay = series_mode

    top_pager_widget = create_pager_widget(
        def (): update_nav_list_url(field, max(0, offset - num), num, query_text, replace=True);,
        def (): update_nav_list_url(field, offset + num, num, query_text, replace=True);,
    )
    body.appendChild(E.div(top_pager_widget.container, style='margin: 0 0.75rem'))
    status = top_pager_widget.count_msg
    if field_info.kind is not 'date':
        search_inp = E.input(
            type='search',
            placeholder=_('Filter…'),
            value=query_text,
            class_='cs-filter'
        )
        search_inp.addEventListener('keydown', def (ev):
            if ev.key is 'Enter':
                ev.preventDefault()
                apply_btn.click()
        )
        apply_btn = create_button(_('Apply'), icons='search', action=def ():
            nonlocal query_text
            query_text = search_inp.value or ''
            update_nav_list_url(field, 0, num, query_text, replace=True)
        )
        top_pager_widget.middle_row.appendChild(search_inp)
        top_pager_widget.middle_row.appendChild(apply_btn)

    def toggle_btn(label, mode):
        a = E.a(href='#', class_='cs-toggle-btn')
        a.setAttribute('aria-pressed', 'true' if group_mode is mode else 'false')
        a.appendChild(E.span(label))
        a.addEventListener('click', def (evt):
            evt.preventDefault()
            update_nav_list_url(field, 0, num, None, replace=True, g=mode)
        )
        return a

    if field_info.kind is 'date':
        group_toggle = E.div(style='display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap')
        group_toggle.appendChild(E.span(_('Group:'), style='opacity:0.75'))
        seg = E.div(class_='cs-toggle')

        seg.appendChild(toggle_btn(_('Year'), 'y'))
        seg.appendChild(toggle_btn(_('Year & Month'), 'ym'))
        group_toggle.appendChild(seg)
        top_pager_widget.middle_row.appendChild(group_toggle)

    top_pager_widget.set_middle_row_visibility(True)
    list_wrap = E.div(class_='cs-list-wrap')
    body.appendChild(list_wrap)
    nav_subtitle = E.p('', class_='cs-nav-subtitle')
    nav_subtitle.style.display = 'none'
    list_container = E.div(class_='cs-list-content')
    bottom_pager = E.div(style='display:none; margin: 0 0.75rem')
    overlay = E.div(class_='cs-loading-overlay', svgicon('cog'), E.span('\xa0' + _('Loading…')))
    list_wrap.appendChild(nav_subtitle)
    list_wrap.appendChild(list_container)
    list_wrap.appendChild(bottom_pager)
    list_wrap.appendChild(overlay)
    list_wrap.classList.add('cs-loading')

    def set_loading(is_loading):
        if is_loading:
            list_wrap.classList.add('cs-loading')
        else:
            # Delay removal slightly to avoid a flash on very fast responses
            setTimeout(def ():
                list_wrap.classList.remove('cs-loading')
            , 60)

    def norm(x):
        return (x or '').toString().toLowerCase()

    def to_item(x):
        if x and jstype(x) is 'object' and 'name' in x:
            return x
        s = (x or '') + ''
        return {'name': s, 'count': None, 'avg_rating': None, 'search_name': s}

    def filtered(items):
        if not query_text:
            return items
        nq = norm(query_text)
        ans = []
        for x in items:
            n = (x.name if (x and jstype(x) is 'object') else x) + ''
            if n and norm(n).indexOf(nq) != -1:
                ans.push(x)
        return ans

    series_cover_targets = {}

    def nav_list_rating_count_parts(count, avg_rating):
        sd = get_session_data()
        show_count = sd.get('show_nav_list_book_count', True)
        show_rating = sd.get('show_nav_list_avg_rating', True)
        count_s = None
        rating_s = None
        if show_count and count is not None and str(count).strip() != '':
            n = int(count)
            count_s = _('{} books').format(n)
        if show_rating and avg_rating is not None and str(avg_rating).strip() != '':
            info = parse_rating_from_api(avg_rating)
            if info and info.value > 0:
                rating_s = create_fractional_rating_stars(info.value)
        if not count_s and not rating_s:
            return None
        return {'rating': rating_s, 'count': count_s}

    def format_count_rating(count, avg_rating):
        p = nav_list_rating_count_parts(count, avg_rating)
        if not p:
            return ''
        parts = []
        if p.count:
            parts.push(p.count)
        if p.rating:
            info = parse_rating_from_api(avg_rating)
            if info:
                parts.push(info.text)
        return ' · '.join(parts)

    def browse_cache_key():
        return tags_cache_key() + '|' + field + '|' + group_mode

    def fetch_browse_items(proceed):
        q = parse_url_params()
        sd = get_session_data()
        query = {'library_id': current_library_id(), 'vl': q.vl or '', 'date_group': group_mode}
        if q.search:
            query.search = q.search
        for k in 'sort_tags_by partition_method collapse_at dont_collapse hide_empty_categories'.split(' '):
            query[k] = sd.get(k) + ''

        def done(end_type, xhr, ev):
            if end_type is not 'load':
                proceed(False, xhr.error_html)
                return
            try:
                data = JSON.parse(xhr.responseText)
            except Exception:
                proceed(False, 'Invalid JSON from server')
                return
            proceed(True, data.items or [])

        ajax(f'interface-data/browse-field/{encodeURIComponent(field)}', done, query=query, bypass_cache=False).send()

    def ensure_series_covers(series_name, target_div):
        if not series_name:
            return
        lid = current_library_id()
        key = series_cover_key(lid, series_name)
        series_cover_targets[key] = (series_cover_targets[key] or [])
        series_cover_targets[key].push(target_div)
        cached = series_cover_cache[key]
        if cached:
            apply_series_covers(key, cached)
            return
        if series_cover_inflight[key]:
            return
        series_cover_inflight[key] = True
        query = {
            'library_id': lid,
            'num': '5',
            'search': f'series:"={escape_for_search(series_name)}"'
        }

        def done(end_type, xhr, ev):
            series_cover_inflight[key] = False
            if end_type is not 'load':
                return
            try:
                data = JSON.parse(xhr.responseText)
            except Exception:
                return
            ids = data.search_result?.book_ids or []
            urls = []
            for bid in ids.slice(0, 5):
                urls.push(cover_url(bid))
            series_cover_cache[key] = urls
            apply_series_covers(key, urls)

        ajax('interface-data/get-books', done, query=query).send()

    def apply_series_covers(key, urls):
        targets = series_cover_targets[key] or []
        for div in targets:
            render_series_stack_covers(div, urls)

    def render_page(data):
        nonlocal series_cover_targets
        set_loading(False)
        series_cover_targets = {}
        raw = data or []
        items = [to_item(x) for x in raw]
        items = filtered(items)
        total = items.length or 0

        def make_action(search_query):
            return def (ev):
                if ev:
                    ev.preventDefault()
                show_panel('book_list', query=search_query, replace=False)

        if field is 'tags':
            shown = min(total, visible_tags)
            status.textContent = _('{0} of {1}').format(shown, total)
            top_pager_widget.set_enabled(False, False)

            clear(list_container)
            subtitle = subtitle_for_field(field)
            if subtitle:
                list_container.appendChild(E.p(subtitle, class_='cs-nav-subtitle'))

            bubbles = E.div(class_='cs-tag-bubbles')
            list_container.appendChild(bubbles)

            for it in items.slice(0, shown):
                if not it or not it.name:
                    continue
                meta = format_count_rating(it.count, it.avg_rating)
                label = it.name + ''
                inner = E.span(label)
                if meta:
                    inner = E.span(E.span(label), E.span(' ' + meta, class_='cs-tag-meta'))
                search_query = {'search': it.search or make_search(field, it.search_name or label).search}
                bubbles.appendChild(E.a(
                    href='#',
                    class_='cs-tag-bubble',
                    onclick=make_action(search_query),
                    title=label,
                    inner
                ))

            if shown < total:
                wrap = E.div(class_='cs-show-more-wrap')
                list_container.appendChild(wrap)
                a = E.a(href='#', class_='cs-show-more')
                a.appendChild(svgicon('plus'))
                a.appendChild(E.span(_('Show more')))
                a.addEventListener('click', def (ev):
                    nonlocal visible_tags
                    ev.preventDefault()
                    visible_tags = min(total, visible_tags + 50)
                    render_page(data)
                )
                wrap.appendChild(a)
            bottom_pager.style.display = 'none'
            return
        if offset >= total and total > 0:
            # Clamp to last page
            new_offset = max(0, (Math.floor((total - 1) / num) * num))
            if new_offset is not offset:
                update_nav_list_url(field, new_offset, num, query_text, replace=True)
                return
        end = min(total, offset + num)
        status.textContent = _('{0}–{1} of {2}').format((offset + 1) if total else 0, end, total)
        top_pager_widget.set_enabled(offset > 0, end < total)

        list_items = []

        def make_action(search_query):
            return def (ev):
                if ev:
                    ev.preventDefault()
                show_panel('book_list', query=search_query, replace=False)

        for it in items.slice(offset, offset + num):
            if not it or not it.name:
                continue
            label = it.name + ''
            search_val = it.search_name or label
            search_query = {'search': it.search or make_search(field, search_val).search}
            meta = nav_list_rating_count_parts(it.count, it.avg_rating)
            if field is 'series':
                series_mode = body.dataset.seriesDisplay or 'stack'
                if series_mode is 'list':
                    list_items.push(create_item(label, action=make_action(search_query), nav_list_meta=meta))
                else:
                    covers = E.div(class_='cs-series-stack-covers')
                    set_series_stack_cover_count(covers, it.count)
                    stack = E.div(class_='cs-series-stack-row', covers, E.div(label, class_='cs-series-stack-name'))
                    stack.dataset.seriesName = label
                    list_items.push(create_item(
                        stack, action=make_action(search_query), nav_list_meta=meta,
                        li_class='cs-series-stack-item', row_class='cs-series-stack-item-row'
                    ))
            elif field is 'rating':
                # Use server-provided display name (tag-browser item.name); do not parse as int — names are localized.
                list_items.push(create_item(label, action=make_action(search_query), nav_list_meta=meta))
            else:
                list_items.push(create_item(label, action=make_action(search_query), nav_list_meta=meta))
        subtitle = subtitle_for_field(field)
        nav_subtitle.textContent = subtitle or ''
        nav_subtitle.style.display = 'block' if subtitle else 'none'
        create_item_list(list_container, list_items)

        def flush_row_heights():
            sync_cs_list_row_heights(list_container)

        flush_row_heights()
        raf = window.requestAnimationFrame
        if raf:
            raf(flush_row_heights)

        if field is 'series':
            # After rendering, lazily fetch covers for visible series
            for srow in list_container.querySelectorAll('[data-series-name]'):
                sname = srow.dataset.seriesName
                cdiv = srow.querySelector('.cs-series-stack-covers')
                if cdiv:
                    ensure_series_covers(sname, cdiv)

        # Bottom prev/next mirrors top pager state
        clear(bottom_pager)
        bot_widget = create_pager_widget(
            def (): update_nav_list_url(field, max(0, offset - num), num, query_text, replace=True);,
            def (): update_nav_list_url(field, offset + num, num, query_text, replace=True);,
        )
        bot_widget.set_enabled(offset > 0, end < total)
        bottom_pager.appendChild(bot_widget.container)
        bottom_pager.style.display = ''

    def render(ok, payload):
        set_loading(False)
        if not ok:
            clear(list_container)
            list_container.appendChild(E.div(_('Failed to load list'), style='opacity:0.9; padding: 1rem'))
            list_container.appendChild(E.div(payload or '', style='opacity:0.8; padding: 0 1rem 1rem 1rem; font-size: smaller'))
            return
        # Update without flashing: create_item_list will clear content itself
        names = payload or []
        cached_names[browse_cache_key()] = names
        render_page(names)

    cached = cached_names[browse_cache_key()]
    if cached:
        render_page(cached)
    else:
        set_loading(True)
        fetch_browse_items(render)


def set_panel_handlers():
    set_panel_handler('nav_list', create_nav_list_panel)
