# -*- coding: utf-8 -*-
"""Home rows of My movies / My series / Continue from the addon's own data (docs/plan/milionar-library/070 §3).

`action=widget_row&row=<id>&limit=<n>&sort=<key>&hw=<0|1>&up=<0|1>` goes through the main.py fast path and reads only
the store and the `meta` cache (never the network). The old rows of MyTV, MyCinema, Milionář, AI, Kraska and TMDb /
ČSFD are the existing `widget_*` builders, addressed by the same parameter (`row=widget_mytv_channels`).
"""
import random
import time
from datetime import date, timedelta
from urllib.parse import quote, quote_plus

import xbmc
import xbmcgui
import xbmcplugin

from wcs import provider_keys
from wcs.library2 import clicks, meta, next_up, store, urls
from wcs.skin import hero

ADDON_ID = 'plugin.video.milionar'
BASE = 'plugin://{}/'.format(ADDON_ID)
POSTER = 'https://image.tmdb.org/t/p/w500'
BACKDROP = 'https://image.tmdb.org/t/p/w780'
NEW_DAYS = 14
ROW_SETTINGS = ('default_movie_action', 'menu_show_ai_chat', 'menu_show_mytv',
                'menu_show_mycinema', 'menu_show_my_collection')

# row id -> (section, default style when a slot has none)
CATALOGUE = {
    'cont_movies': ('cont', 'landscape'), 'cont_series': ('cont', 'episode'), 'recent_played': ('cont', 'poster'),
    'my_movies_progress': ('my_movies', 'landscape'), 'my_movies_added': ('my_movies', 'poster'),
    'my_movies_unwatched': ('my_movies', 'poster'), 'my_movies_genres': ('my_movies', 'categories'),
    'my_movies_watched': ('my_movies', 'poster'), 'my_movies_all': ('my_movies', 'poster'),
    'my_series_next': ('my_series', 'episode'), 'my_series_new': ('my_series', 'episode'),
    'my_series_added': ('my_series', 'poster'), 'my_series_unwatched': ('my_series', 'poster'),
    'my_series_genres': ('my_series', 'categories'), 'my_series_upcoming': ('my_series', 'episode'),
    'my_series_all': ('my_series', 'poster'),
}


SEARCH_ROWS = ('search_my_movies', 'search_my_series')          # rows of the search window (090): matches in the lists


def fold(text):
    """Case and accent insensitive form of a text for matching titles."""
    import unicodedata
    return ''.join(c for c in unicodedata.normalize('NFKD', str(text or '')) if not unicodedata.combining(c)).lower()


def _image(base, path):
    if not path:
        return ''
    return base + path if path.startswith('/') and not path.startswith('/Users') else path


def _set_art(item, art):
    art = {k: v for k, v in art.items() if v}
    try:
        from wcs.skin.performance import artwork, enabled
        if enabled():
            mapped = {url: artwork(url) for url in set(art.values())}
            art = {k: mapped[v] for k, v in art.items()}
    except ImportError:
        pass
    item.setArt(art)


def _stamp(epoch):
    return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(epoch)) if epoch else ''


def _run_url(query):
    return '{}?action=skin_run&run={}'.format(BASE, quote(query, safe=''))


# --------------------------------------------------------------------------- data

class Data:
    """Everything a row needs, loaded lazily and once (a handful of queries per widget call)."""

    def __init__(self, today=None):
        self.today = today or date.today()
        self._cache = {}
        self.complete = True
        self.missing = set()

    def _once(self, key, make):
        if key not in self._cache:
            self._cache[key] = make()
        return self._cache[key]

    def titles(self, media_type):
        def make():
            conn = store.connect()
            try:
                rows = conn.execute('SELECT * FROM titles WHERE media_type=?', (media_type,)).fetchall()
            finally:
                conn.close()
            return {r['tmdb_id']: dict(r) for r in rows}
        return self._once(('titles', media_type), make)

    def list_ids(self, media_type):
        return self._once(('list', media_type), lambda: [i for i, r in sorted(self.titles(media_type).items(),
                                     key=lambda kv: (kv[1]['list_pos'] if kv[1]['list_pos'] is not None else 10 ** 9, kv[0]))
                if r['in_list']])

    def movie_states(self):
        return self._once('mstates', store.get_movie_states)

    def episode_states(self):
        return self._once('estates', store.get_all_episode_states)

    def snapshot(self, media_type, tmdb_id):
        def make():
            row = self.titles(media_type).get(tmdb_id) or {}
            try:
                import json
                return json.loads(row['legacy_json']) if row.get('legacy_json') else {}
            except (ValueError, KeyError):
                return {}
        return self._once(('snapshot', media_type, tmdb_id), make)

    def meta_many(self, keys):
        keys = list(dict.fromkeys(keys))
        wanted = [k for k in keys if k not in self._cache]
        if wanted:
            found = meta.get_many(wanted)
            for key in wanted:
                self._cache[key] = found.get(key) or {}
                media, tmdb_id = key.split(':')[:2]
                required = {'movie': 'genres', 'tv': 'seasons', 'season': 'episodes'}.get(media)
                if not self._cache[key] or (required and required not in self._cache[key]):
                    self.complete = False
                    self.missing.add('library:{}:{}'.format('tv' if media == 'season' else media, tmdb_id))
        return {k: self._cache[k] for k in keys}

    def movie(self, tmdb_id):
        return self.meta_many([meta.key_movie(tmdb_id)])[meta.key_movie(tmdb_id)]

    def show(self, tmdb_id):
        return self.meta_many([meta.key_tv(tmdb_id)])[meta.key_tv(tmdb_id)]

    def preload_movies(self, ids):
        self.meta_many(meta.key_movie(i) for i in ids)

    def entries(self, media_type):
        titles = self.titles(media_type)
        # Native listings retain store.get_list() ordering, including its added-time tie break.
        ids = sorted((i for i, r in titles.items() if r['in_list']),
                     key=lambda i: (titles[i]['list_pos'] is not None, titles[i]['list_pos'] or 0,
                                    titles[i]['list_added_at'] is not None, titles[i]['list_added_at'] or 0, i))
        entries = []
        for tmdb_id in ids:
            entry = dict(self.snapshot(media_type, tmdb_id))
            entry.setdefault('id', tmdb_id)
            entries.append(entry)
        return entries

    def list_times(self, media_type):
        return {i: {'added': r['list_added_at'], 'activity': r['last_activity_at']}
                for i, r in self.titles(media_type).items() if r['in_list']}

    def preload_series(self, ids):
        shows = self.meta_many([meta.key_tv(i) for i in ids])
        self.meta_many([meta.key_season(i, int(s['season_number'])) for i in ids
                        for s in shows[meta.key_tv(i)].get('seasons') or [] if int(s.get('season_number', 0)) > 0])

    def series_state(self, tmdb_id):
        """(show, order, next_up result) of a series from the cache; order is [] when no season data is cached."""
        def make():
            show = self.show(tmdb_id)
            seasons = {}
            for entry in show.get('seasons') or []:
                number = int(entry.get('season_number', 0))
                if number > 0:
                    data = self.meta_many([meta.key_season(tmdb_id, number)])[meta.key_season(tmdb_id, number)]
                    if 'episodes' in data:
                        seasons[number] = data
            order = next_up.episode_order(show, seasons, self.today) if show else []
            states = self.episode_states().get(tmdb_id, {})
            return show, order, next_up.next_up(order, states, show.get('status'), self.today) if order else \
                {'kind': None, 'episode': None}
        return self._once(('series', tmdb_id), make)


# --------------------------------------------------------------------------- cards (plain dicts)

def _genre(detail):
    """Name of the first genre of a TMDb detail."""
    return next((g.get('name') for g in detail.get('genres') or [] if g.get('name')), '')


def movie_card(data, tmdb_id):
    detail, snap = data.movie(tmdb_id), data.snapshot('movie', tmdb_id)
    state = data.movie_states().get(tmdb_id) or {}
    title = detail.get('title') or snap.get('title') or snap.get('name') or 'Film {}'.format(tmdb_id)
    row = data.titles('movie').get(tmdb_id) or {}
    return {'kind': 'movie', 'id': tmdb_id, 'title': title, 'original': detail.get('original_title') or title, 'year': (detail.get('release_date') or '')[:4] or str(snap.get('year') or ''),
            'rating': float(detail.get('vote_average') or snap.get('vote_average') or 0),
            'plot': detail.get('overview') or snap.get('overview') or '',
            'poster': _image(POSTER, detail.get('poster_path') or snap.get('poster_path') or snap.get('poster')),
            'fanart': _image(BACKDROP, detail.get('backdrop_path') or snap.get('backdrop_path')),
            'backdrop': detail.get('backdrop_path') or snap.get('backdrop_path') or '', 'runtime': int(detail.get('runtime') or 0),
            'genre': _genre(detail),
            'genres': [g.get('id') for g in detail.get('genres') or []], 'genre_names': {g.get('id'): g.get('name') for g in detail.get('genres') or []},
            'playcount': int(state.get('playcount') or 0), 'resume': float(state.get('resume_pos') or 0),
            'resume_total': float(state.get('resume_total') or 0), 'time': state.get('last_played_at') or 0,
            'added': row.get('list_added_at') or 0, 'activity': row.get('last_activity_at') or 0,
            'hidden': bool(row.get('hidden_from_continue_at')), 'played': bool(state.get('last_played_at'))}


def series_facts(show, snap, tmdb_id):
    """What a series shows on a card and in the hero: its TMDb detail `show`, else the stored entry `snap`."""
    name = show.get('name') or snap.get('name') or snap.get('title') or 'Seriál {}'.format(tmdb_id)
    backdrop = show.get('backdrop_path') or snap.get('backdrop_path') or ''
    return {'id': tmdb_id, 'title': name, 'original': show.get('original_name') or name,
            'year': (show.get('first_air_date') or '')[:4] or str(snap.get('year') or ''),
            'rating': float(show.get('vote_average') or snap.get('vote_average') or 0),
            'plot': show.get('overview') or snap.get('overview') or '',
            'poster': _image(POSTER, show.get('poster_path') or snap.get('poster_path') or snap.get('poster')),
            'fanart': _image(BACKDROP, backdrop), 'backdrop': backdrop, 'genre': _genre(show),
            'seasons': int(show.get('number_of_seasons') or 0), 'end_year': (show.get('last_air_date') or '')[:4],
            'ended': show.get('status') in next_up.ENDED_STATUSES if show.get('status') else None}


def series_card(data, tmdb_id):
    show, snap = data.show(tmdb_id), data.snapshot('tv', tmdb_id)
    row = data.titles('tv').get(tmdb_id) or {}
    _show, order, state = data.series_state(tmdb_id)
    states = data.episode_states().get(tmdb_id, {})
    return dict(series_facts(show, snap, tmdb_id), **{
            'kind': 'tv',
            'genres': [g.get('id') for g in show.get('genres') or []], 'genre_names': {g.get('id'): g.get('name') for g in show.get('genres') or []},
            'added': row.get('list_added_at') or 0, 'activity': row.get('last_activity_at') or 0,
            'hidden': bool(row.get('hidden_from_continue_at')), 'started': bool(states) or bool(row.get('last_activity_at')),
            'watched': next_up.series_watched(order, states) if order else False, 'order': order, 'state': state,
            'show': show, 'states': states})


# --------------------------------------------------------------------------- cards -> ListItems

def _art(item, card, landscape=False):
    art = {'poster': card.get('poster'), 'thumb': card.get('poster'), 'icon': card.get('poster'),
           'fanart': card.get('fanart') or card.get('poster')}
    if landscape:                                   # the poster widget draws Art(poster): put the wide image there
        wide = card.get('fanart') or card.get('poster')
        art.update({'poster': wide, 'thumb': wide, 'icon': wide, 'landscape': wide})
    _set_art(item, art)


def _meta(card):
    return {'title': card['title'], 'original': card.get('original'), 'plot': card['plot'], 'poster': card['poster'],
            'fanart': card.get('fanart')}


def _hero_buttons(media, card, season=None, episode=None, unaired_text=None):
    """Builtins of the two hero buttons: play (resume, or a series' next episode) and the details dialog."""
    return [clicks.exec_for(*clicks.title_path(media, card['id'], option, season, episode, _meta(card), unaired_text))
            for option in ('play', 'detail')]


def movie_item(card, landscape=False, click='detail'):
    """Movie card -> item; `click` is the widget's click option (clicks.py)."""
    path, folder = clicks.title_path('movie', card['id'], click, meta=_meta(card))
    item = xbmcgui.ListItem(label=card['title'], path=path, offscreen=True)
    if not folder:
        item.setProperty('IsPlayable', 'true')
    tag = item.getVideoInfoTag()
    tag.setMediaType('movie')
    tag.setTitle(card['title'])
    if str(card['year']).isdigit():
        tag.setYear(int(card['year']))
    tag.setPlot(card['plot'])
    if card['rating']:
        tag.setRating(card['rating'])
    tag.setPlaycount(card['playcount'])
    if card['resume']:
        tag.setResumePoint(card['resume'], card['resume_total'])
    if card['time']:
        tag.setLastPlayed(_stamp(card['time']))
    _art(item, card, landscape)
    item.setProperty('milionar.type', 'movie')
    item.setProperty('milionar.tmdb', str(card['id']))
    hero.apply(item, hero.movie(card), *_hero_buttons('movie', card), resumable=bool(card['resume']))
    return item.getPath(), item, folder


def series_item(card, landscape=False, click='folder'):
    path, folder = clicks.title_path('tv', card['id'], click, meta=_meta(card))
    item = xbmcgui.ListItem(label=card['title'], path=path, offscreen=True)
    if not folder:
        item.setProperty('IsPlayable', 'true')
    tag = item.getVideoInfoTag()
    tag.setMediaType('tvshow')
    tag.setTitle(card['title'])
    tag.setTvShowTitle(card['title'])
    if str(card['year']).isdigit():
        tag.setYear(int(card['year']))
    tag.setPlot(card['plot'])
    if card['rating']:
        tag.setRating(card['rating'])
    _art(item, card, landscape)
    if 'counts' in card:
        total, watched = card['counts']
    else:
        aired = [e for e in card['order'] if e['aired']]
        total = len(aired) or int(card['show'].get('number_of_episodes') or 0)
        watched = sum(1 for e in aired if int((card['states'].get((e['s'], e['e'])) or {}).get('playcount') or 0) > 0)
    tag.setPlaycount(1 if total and watched >= total else 0)
    if total:                       # unknown episode count (a recommendation outside the lists): no "0 / 0 ✓" badge
        for key, value in (('TotalEpisodes', total), ('NumEpisodes', total), ('WatchedEpisodes', watched),
                           ('UnWatchedEpisodes', max(total - watched, 0))):
            item.setProperty(key, str(value))                                # the poster badge / tick of Estuary
    item.setProperty('milionar.type', 'tv')
    item.setProperty('milionar.tmdb', str(card['id']))
    hero.apply(item, hero.series(card), *_hero_buttons('tv', card), ids=('next', 'detail'))
    return item.getPath(), item, folder


def episode_item(card, episode, kind, click='play'):
    """Item for an episode of a series card; `kind` resume | next | upcoming | new | recent; `click` as in clicks.py."""
    number, ep_no = episode['s'], episode['e']
    state = card.get('episode_state') or card.get('states', {}).get((number, ep_no)) or {}
    upcoming = kind == 'upcoming'
    when = next_up.air_label(episode.get('air_date'))
    title = ('vyjde {}'.format(when) if when else 'vyjde') if upcoming else (episode.get('name') or 'Epizoda {}'.format(ep_no))
    note = '{} S{:02d}E{:02d} vyjde {}'.format(card['title'], number, ep_no, when) if upcoming else None
    path, folder = clicks.title_path('episode', card['id'], click, number, ep_no, _meta(card), unaired_text=note)
    item = xbmcgui.ListItem(label=title, path=path, offscreen=True)
    if not folder:
        item.setProperty('IsPlayable', 'true')
    tag = item.getVideoInfoTag()
    tag.setMediaType('episode')
    tag.setTitle(title)
    tag.setTvShowTitle(card['title'])
    tag.setSeason(number)
    tag.setEpisode(ep_no)
    if state.get('resume_pos'):
        tag.setResumePoint(float(state['resume_pos']), float(state.get('resume_total') or 0))
    if state.get('playcount'):
        tag.setPlaycount(int(state['playcount']))
    still = _image(BACKDROP, episode.get('still'))
    art = {'thumb': still or card['fanart'] or card['poster'], 'icon': still or card['poster'], 'poster': card['poster'],
           'fanart': card['fanart'] or card['poster'], 'landscape': still or card['fanart']}
    _set_art(item, art)
    item.setProperty('milionar.type', 'episode')
    item.setProperty('milionar.tmdb', str(card['id']))
    item.setProperty('milionar.s', str(number))
    item.setProperty('milionar.e', str(ep_no))
    hero.apply(item, hero.episode(card, episode, state, status=title if upcoming else ''),
               *_hero_buttons('episode', card, number, ep_no, note), resumable=bool(state.get('resume_pos')))
    return item.getPath(), item, folder


def genre_item(kind, genre_id, name, count):
    item = xbmcgui.ListItem(label='{} ({})'.format(name, count),
                            path='{}?action=mlist&kind={}&genre={}&skin=1'.format(BASE, kind, genre_id), offscreen=True)
    item.getVideoInfoTag().setTitle(name)
    item.setArt({'icon': 'DefaultGenre.png', 'thumb': 'DefaultGenre.png'})
    return item.getPath(), item, True


# --------------------------------------------------------------------------- row builders: selected plain descriptors

SORT_KEYS = {
    'activity': (lambda c: -c.get('activity', 0)), 'added': (lambda c: -c.get('added', 0)),
    'time': (lambda c: -c.get('time', c.get('activity', 0))), 'title': (lambda c: c['title'].lower()),
    'year': (lambda c: -(int(c['year']) if str(c.get('year', '')).isdigit() else 0)), 'rating': (lambda c: -c.get('rating', 0)),
    'air': (lambda c: c.get('air', '9999')),
}


def _sorted(cards, sort, default):
    sort = sort if sort in SORT_KEYS or sort == 'random' else default
    if sort == 'random':
        cards = list(cards)
        random.shuffle(cards)
        return cards
    return sorted(cards, key=SORT_KEYS[sort])


def _movie_row(row, data, sort, hide_watched, style='', click='', limit=20):
    ids = data.list_ids('movie')
    if row == 'cont_movies':
        ids = [i for i, st in data.movie_states().items() if float(st.get('resume_pos') or 0) > 0]
    data.preload_movies(ids)
    cards = [movie_card(data, i) for i in ids]
    if row in ('cont_movies', 'my_movies_progress'):
        cards = [c for c in cards if c['resume'] > 0 and c['playcount'] == 0 and not (row == 'cont_movies' and c['hidden'])]
        default = 'activity'
    elif row == 'my_movies_added':
        default = 'added'
    elif row == 'my_movies_unwatched':
        cards, default = [c for c in cards if c['playcount'] == 0 and not c['played']], 'added'
    elif row == 'my_movies_watched':
        cards, default = [c for c in cards if c['playcount'] > 0], 'time'
    else:
        default = 'title'
    if hide_watched and row not in ('my_movies_watched',):
        cards = [c for c in cards if c['playcount'] == 0]
    landscape = (style or CATALOGUE[row][1]) == 'landscape'
    click = clicks.resolve(row, click)
    return [_descriptor('movie', c, click, landscape) for c in _sorted(cards, sort, default)[:limit]]


def _series_cards(data, ids):
    data.preload_series(ids)
    return [series_card(data, i) for i in ids]


def _series_row(row, data, sort, hide_watched, upcoming, style='', click='', limit=20):
    click = clicks.resolve(row, click)
    if row == 'cont_series':
        ids = [i for i, r in data.titles('tv').items() if r.get('last_activity_at')]
    else:
        ids = data.list_ids('tv')
    cards = _series_cards(data, ids)
    today = data.today
    if row in ('cont_series', 'my_series_next', 'my_series_new', 'my_series_upcoming'):
        items = []
        for card in cards:
            if card['hidden'] and row == 'cont_series':
                continue
            state = card['state']
            if row in ('cont_series', 'my_series_next'):
                if state['kind'] in ('resume', 'next') or (state['kind'] == 'upcoming' and upcoming):
                    items.append((card, state['episode'], state['kind']))
            elif row == 'my_series_new':
                cutoff = (today - timedelta(days=NEW_DAYS)).isoformat()
                fresh = [e for e in card['order'] if e['aired'] and e['air_date'] >= cutoff
                         and int((card['states'].get((e['s'], e['e'])) or {}).get('playcount') or 0) == 0]
                if fresh:
                    items.append((card, max(fresh, key=lambda e: e['air_date']), 'new'))
            else:
                ahead = [e for e in card['order'] if not e['aired'] and e['air_date']]
                if ahead:
                    items.append((card, min(ahead, key=lambda e: e['air_date']), 'upcoming'))
        if row == 'my_series_upcoming':
            items.sort(key=lambda t: t[1]['air_date'])
        elif row == 'my_series_new':
            items.sort(key=lambda t: t[1]['air_date'], reverse=True)
        else:
            items = _sorted_pairs(items, sort)
        return [_descriptor('episode', c, click, episode=e, episode_kind=k) for c, e, k in items[:limit]]
    if row == 'my_series_unwatched':
        cards, default = [c for c in cards if not c['started']], 'added'
    elif row == 'my_series_added':
        default = 'added'
    else:
        default = 'title'
    if hide_watched:
        cards = [c for c in cards if not c['watched']]
    return [_descriptor('tv', c, click, (style or CATALOGUE[row][1]) == 'landscape')
            for c in _sorted(cards, sort, default)[:limit]]


def _sorted_pairs(items, sort):
    cards = _sorted([t[0] for t in items], sort, 'activity')
    order = {id(c): i for i, c in enumerate(cards)}
    return sorted(items, key=lambda t: order[id(t[0])])


def _genre_row(row, data, limit):
    media = 'movie' if row.startswith('my_movies') else 'tv'
    ids = data.list_ids(media)
    details = data.meta_many(meta.key_movie(i) if media == 'movie' else meta.key_tv(i) for i in ids)
    counts, names = {}, {}
    for key, detail in details.items():
        if 'genres' not in detail:
            data.complete = False
            data.missing.add('library:{}:{}'.format(media, key.split(':')[1]))
        for genre in detail.get('genres') or []:
            g = genre.get('id')
            counts[g] = counts.get(g, 0) + 1
            names[g] = genre.get('name') or str(g)
    kind = 'movies' if media == 'movie' else 'series'
    return [{'kind': 'genre', 'args': [kind, g, names[g], counts[g]]}
            for g in sorted(counts, key=lambda g: names[g].lower())[:limit]]


def _recent_row(data, click='', limit=20):
    """Latest distinct titles of the play log (a series shows its latest episode)."""
    click = clicks.resolve('recent_played', click)
    seen, logs = set(), []
    for log in store.recent_logs(80):
        key = (log['media_type'], log['tmdb_id'])
        if key in seen or not log.get('started_at'):
            continue
        seen.add(key)
        logs.append(log)
    logs = logs[:limit]
    data.preload_movies([log['tmdb_id'] for log in logs if log['media_type'] == 'movie'])
    data.preload_series([log['tmdb_id'] for log in logs if log['media_type'] != 'movie'])
    items = []
    for log in logs:
        if log['media_type'] == 'movie':
            items.append(_descriptor('movie', movie_card(data, log['tmdb_id']), click))
        else:
            card = series_card(data, log['tmdb_id'])
            episode = next((e for e in card['order'] if (e['s'], e['e']) == (log.get('season'), log.get('episode'))),
                           {'s': log.get('season') or 1, 'e': log.get('episode') or 1, 'name': '', 'air_date': ''})
            items.append(_descriptor('episode', card, click, episode=episode, episode_kind='recent'))
    return items


def _descriptor(kind, card, click, landscape=False, episode=None, episode_kind=''):
    """Retain only display fields: no season payloads or tuple-keyed watch-state maps on disk."""
    plain = {k: v for k, v in card.items() if k not in
             ('order', 'show', 'states', 'state', 'genres', 'genre_names')}
    if kind == 'tv':
        aired = [e for e in card['order'] if e['aired']]
        plain['counts'] = [len(aired) or int(card['show'].get('number_of_episodes') or 0),
                           sum(1 for e in aired if int((card['states'].get((e['s'], e['e'])) or {}).get('playcount') or 0) > 0)]
    if episode is not None:
        state = card['states'].get((episode['s'], episode['e'])) or {}
        plain['episode_state'] = {k: state.get(k) for k in ('playcount', 'resume_pos', 'resume_total')}
    return {'kind': kind, 'card': plain, 'click': click, 'landscape': landscape,
            'episode': episode, 'episode_kind': episode_kind}


def _prepare(descriptors, data):
    from wcs.library2 import menu
    model = None
    for entry in descriptors:
        kind = entry['kind']
        if kind == 'genre':
            continue
        # Upcoming items keep the same context eligibility as attach_items().
        if entry['episode_kind'] == 'upcoming' and entry['click'] in clicks.UNAIRED_NOTE:
            continue
        ep = entry.get('episode') or {}
        try:
            if model is None:
                model = menu.Menu(data)
            entry['menu'] = model.build(kind, entry['card']['id'], ep.get('s'), ep.get('e'))
        except Exception as exc:
            data.complete = False
            xbmc.log('[Milionář Library] context menu failed: {}: {}'.format(type(exc).__name__, exc), xbmc.LOGWARNING)
    return descriptors


def _render(descriptors):
    items = []
    for entry in descriptors:
        kind = entry['kind']
        if kind == 'genre':
            path, item, folder = genre_item(*entry['args'])
        elif kind == 'episode':
            path, item, folder = episode_item(entry['card'], entry['episode'], entry['episode_kind'], entry['click'])
        else:
            factory = movie_item if kind == 'movie' else series_item
            path, item, folder = factory(entry['card'], entry['landscape'], entry['click'])
        if entry.get('menu'):
            item.addContextMenuItems([tuple(pair) for pair in entry['menu']], True)
        item.setProperty('milionar.exec', clicks.exec_for(path, folder))
        items.append((path, item, folder))
    return items


def build(row, params):
    """Use a persistent plain snapshot, then construct only the selected fresh offscreen items."""
    from wcs.skin import row_cache
    import xbmcaddon
    data = Data()
    addon = xbmcaddon.Addon(ADDON_ID)
    params = dict(params, limit=str(_limit(params)))
    scope = {'db': store.db_path(), 'date': data.today.isoformat(),
             'language': xbmc.getLanguage(xbmc.ISO_639_1),            # no GUI lock, unlike the info label
             'profile': addon.getAddonInfo('profile'),
             'config': {name: addon.getSetting(name) for name in ROW_SETTINGS},
             'default_click': clicks.default(row),
             'ai_available': provider_keys.flag('ai')}
    key = row_cache.key(row, params, scope)
    snapshot = row_cache.read(key)
    if snapshot is not None:
        try:
            return _render(snapshot)
        except Exception:
            pass                                      # malformed snapshot: rebuild from the authoritative stores
    snapshot = _prepare(_build(row, params, data), data)
    items = _render(snapshot)
    if data.missing:
        try:
            from wcs.skin.refresh import request
            request(sorted(data.missing))
        except Exception:
            pass                                      # refresh failure must not prevent serving available cards
    if data.complete:
        row_cache.write(key, snapshot)
    return items


def _search_row(row, params, data, limit):
    query = fold(params.get('q'))
    if not query:
        return []
    movie = row == 'search_my_movies'
    ids = data.list_ids('movie' if movie else 'tv')
    if movie:
        data.preload_movies(ids)
    else:
        data.meta_many(meta.key_tv(i) for i in ids)
    # Match titles before computing expensive series episode summaries.
    found = []
    for tmdb_id in ids:
        detail = data.movie(tmdb_id) if movie else data.show(tmdb_id)
        snap = data.snapshot('movie' if movie else 'tv', tmdb_id)
        title = ((detail.get('title') or snap.get('title') or snap.get('name') or 'Film {}'.format(tmdb_id)) if movie
                 else (detail.get('name') or snap.get('name') or snap.get('title') or 'Seriál {}'.format(tmdb_id)))
        original = detail.get('original_title') if movie else detail.get('original_name')
        if query in fold(title) or query in fold(original):
            found.append(tmdb_id)
    found = found[:limit]
    if not movie:
        data.preload_series(found)
    return [_descriptor('movie' if movie else 'tv', movie_card(data, i) if movie else series_card(data, i),
                        'detail' if movie else 'folder') for i in found]


def _limit(params):
    try:
        raw = params.get('limit')
        value = max(0, int(20 if raw in (None, '') else raw))
    except (TypeError, ValueError):
        value = 20
    try:
        from wcs.skin.performance import limit
        return limit(value)
    except ImportError:
        return value


def _build(row, params, data):
    limit = int(params['limit'])
    if limit == 0:
        return []
    if row in SEARCH_ROWS:
        return _search_row(row, params, data, limit)
    sort = params.get('sort') or ''
    hide_watched = params.get('hw') == '1'
    upcoming = params.get('up', '1') != '0'
    if row in ('cont_movies', 'my_movies_progress', 'my_movies_added', 'my_movies_unwatched', 'my_movies_watched', 'my_movies_all'):
        return _movie_row(row, data, sort, hide_watched, params.get('style') or '', params.get('click') or '', limit)
    if row in ('my_movies_genres', 'my_series_genres'):
        return _genre_row(row, data, limit)
    if row == 'recent_played':
        return _recent_row(data, params.get('click') or '', limit)
    return _series_row(row, data, sort, hide_watched, upcoming, params.get('style') or '', params.get('click') or '', limit)


def run(handle, params):
    started = time.perf_counter()
    row = params.get('row', '')
    from wcs.skin import navigation
    if row in navigation.ROWS:
        return navigation.widget(handle, row, params)
    if row.startswith('fy_'):
        from wcs.foryou import widgets as foryou_widgets       # Pro tebe (docs/plan/pro-tebe/030)
        if row in foryou_widgets.ALL_ROWS:
            return foryou_widgets.run(handle, params)
    if row.startswith('vinyl_'):
        from wcs.vinyl import widgets as vinyl_widgets
        if row in vinyl_widgets.ROWS:
            return vinyl_widgets.run(handle, params)
    if row not in CATALOGUE and row not in SEARCH_ROWS:
        from wcs.skin import widgets                      # the existing rows: row=widget_mytv_channels ...
        if row in widgets.WIDGETS:
            return widgets.run(handle, dict(params, action=row))
        xbmcplugin.endOfDirectory(handle, succeeded=True, cacheToDisc=False)
        return
    items = []
    try:
        items = build(row, params)
    except Exception as exc:                              # a broken row must never break Home
        xbmc.log('[Milionář Library] row {} failed: {}: {}'.format(row, type(exc).__name__, exc), xbmc.LOGERROR)
    xbmcplugin.addDirectoryItems(handle, items, len(items))
    xbmcplugin.endOfDirectory(handle, succeeded=True, cacheToDisc=False)
    xbmc.log('[Milionář Library] widget_row {} -> {} items in {:.0f} ms'.format(
        row, len(items), (time.perf_counter() - started) * 1000), xbmc.LOGDEBUG)
