# -*- coding: utf-8 -*-
"""Home rows prepared by the service and handed over by the plugin call (docs/plan/skin-optimization, B5).

A Home row stays a plugin list: only a list with content of its own runs its click, context menu, mouse and touch in
Kodi. What made a row call slow was building the row in a fresh plugin run. Now

  * the service builds every row beforehand, exactly as its plugin listing does (the real entry point
    `wcs.library2.rows.run` with the parameters of the row's URL), and writes down every call that filled an item
    (`Item`: a list item that keeps a journal of its `set...` / `add...` calls);
  * the plugin call (`serve`, the first thing main.py tries for `action=widget_row`) reads the journal of exactly
    those parameters and repeats the calls: the same items, without importing or computing anything.

A journal is kept in a Home property and on disk, so after a restart of Kodi the rows are served before the service
has even started. It belongs to one set of URL parameters, one revision of the row's data in the library and one
version of the code; when there is none the plugin call builds the row itself, as before. So a row is never older
than its data, also when the service is busy or not running.

Kept light on purpose: main.py imports this module in every row call.
"""
import json
import os
import threading
import time
import zlib

import xbmc
import xbmcgui
import xbmcvfs
from wcs.skin import family

PROPERTY = 'milionar.journal.'
DIRECTORY = 'special://profile/addon_data/plugin.video.milionar/row_journal/'
MAX_ITEMS = 20                           # cards per Home row
KEEP = 6                                 # journals per slot (other limits, sorts, Pro tebe modes)
TARGETS = {'video': 'getVideoInfoTag', 'music': 'getMusicInfoTag'}
PERF = '[DEBUG][Milionář Perf] '         # as wcs/skin/perflog.py (not imported: this module stays light)
RETRY_AFTER = 300.0                      # seconds before a row that could not be prepared is tried again
_local = threading.local()
_build = threading.Lock()                # one build at a time: it swaps functions of xbmcplugin
_real_item = None
_stamp = []


def stamp():
    """The version of the code that made a journal: the time main.py was installed."""
    if not _stamp:
        root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        _stamp.append(int(os.path.getmtime(os.path.join(root, 'main.py'))))
    return _stamp[0]


def normalize(params):
    """The row's parameters as both sides see them: no empty values (Kodi's URL parsing drops them), at most
    MAX_ITEMS cards."""
    values = {key: str(value) for key, value in params.items() if value not in ('', None)}
    if values.get('sec') and values.get('slot'):
        try:
            limit = int(values.get('limit') or MAX_ITEMS)
        except ValueError:
            limit = MAX_ITEMS
        values['limit'] = str(min(limit, MAX_ITEMS))
    return values


def name(params):
    """<sec>.<slot>.<hash of every parameter and Kodi's language>: one journal per exact request."""
    raw = json.dumps(sorted((key, value) for key, value in params.items() if key != 'action')
                     + [xbmc.getInfoLabel('System.Language')]).encode('utf-8')
    return '{}.{}.{:08x}{:08x}'.format(params.get('sec', ''), params.get('slot', ''), zlib.crc32(raw), zlib.adler32(raw))


def _revision(row):
    """The revision of the row type's data (wcs/skin/refresh.py); None for a row that has none."""
    try:
        from wcs.skin.refresh import revisions
        return revisions([row]).get(row)
    except Exception:
        return None


def _path(entry_name):
    return os.path.join(xbmcvfs.translatePath(DIRECTORY), entry_name + '.json')


# --------------------------------------------------------------------------- writing a journal (service)

class _Tag:
    """An info tag of an item: its `set...` / `add...` calls go into the item's journal."""

    def __init__(self, real, calls, target):
        self._real, self._calls, self._target = real, calls, target

    def __getattr__(self, attribute):
        value = getattr(self._real, attribute)
        if not callable(value) or not attribute.startswith(('set', 'add')):
            return value

        def call(*args, **kwargs):
            self._calls.append([self._target, attribute, args, kwargs])
            return value(*args, **kwargs)
        return call


class Item:
    """xbmcgui.ListItem that writes down how it was filled."""

    def __init__(self, *args, **kwargs):
        self._real = _real_item(*args, **kwargs)
        self._calls = [['new', '', args, kwargs]]

    def getVideoInfoTag(self):
        return _Tag(self._real.getVideoInfoTag(), self._calls, 'video')

    def getMusicInfoTag(self):
        return _Tag(self._real.getMusicInfoTag(), self._calls, 'music')

    def __getattr__(self, attribute):
        value = getattr(self._real, attribute)
        if not callable(value) or not attribute.startswith(('set', 'add')):
            return value

        def call(*args, **kwargs):
            self._calls.append(['item', attribute, args, kwargs])
            return value(*args, **kwargs)
        return call


def _factory(*args, **kwargs):
    if getattr(_local, 'recording', False):
        return Item(*args, **kwargs)
    return _real_item(*args, **kwargs)


def install():
    """xbmcgui.ListItem keeps a journal while this thread builds a row (service process only)."""
    global _real_item
    if _real_item is None:
        _real_item = xbmcgui.ListItem
        xbmcgui.ListItem = _factory


def installed():
    """True in the process that prepares rows (the service)."""
    return _real_item is not None


class _Catch:
    """The directory functions of xbmcplugin for the time of one build: the items are kept, nothing is sent."""

    def __init__(self):
        self.items, self.content = [], ''

    def __enter__(self):
        import xbmcplugin
        self.module = xbmcplugin
        names = ('addDirectoryItem', 'addDirectoryItems', 'endOfDirectory', 'setContent', 'setPluginCategory',
                 'addSortMethod', 'setResolvedUrl', 'setProperty')
        self.saved = {key: getattr(xbmcplugin, key) for key in names if hasattr(xbmcplugin, key)}
        self.originals = getattr(xbmcplugin, '_milionar_directory_originals', None)
        for key in self.saved:
            setattr(xbmcplugin, key, lambda *args, **kwargs: True)
        xbmcplugin.setContent = self.set_content
        # wcs.skin.context.install_listing_context wraps these two with the context / hero decoration of the items
        xbmcplugin._milionar_directory_originals = (self.one, self.many)
        xbmcplugin.addDirectoryItem, xbmcplugin.addDirectoryItems = self.one, self.many
        _local.recording = True
        return self

    def set_content(self, handle, content):
        self.content = content
        return True

    def one(self, handle, url, listitem, isFolder=False, totalItems=0):
        self.items.append((url, listitem, isFolder))
        return True

    def many(self, handle, items, totalItems=0):
        self.items.extend(items)
        return True

    def __exit__(self, *exc):
        _local.recording = False
        for key, function in self.saved.items():
            setattr(self.module, key, function)
        if self.originals is None:
            del self.module._milionar_directory_originals
        else:
            self.module._milionar_directory_originals = self.originals
        return False


def _pack(value):
    """JSON keeps no tuples, Kodi's list item calls want them back."""
    if isinstance(value, tuple):
        return {'__tuple__': [_pack(part) for part in value]}
    if isinstance(value, list):
        return [_pack(part) for part in value]
    if isinstance(value, dict):
        return {str(key): _pack(part) for key, part in value.items()}
    if value is None or isinstance(value, (str, int, float, bool)):
        return value
    raise TypeError('not a plain value: {}'.format(type(value).__name__))


def _unpack(value):
    if isinstance(value, dict):
        if len(value) == 1 and '__tuple__' in value:
            return tuple(_unpack(part) for part in value['__tuple__'])
        return {key: _unpack(part) for key, part in value.items()}
    if isinstance(value, list):
        return [_unpack(part) for part in value]
    return value


def build(params):
    """Build the row of these parameters as its plugin listing does: the journal entry, or None when an item
    cannot be written down (the plugin call then builds that row itself)."""
    from wcs.library2 import rows
    from wcs.skin import context
    install()
    revision = _revision(params.get('row', ''))        # read first: a change during the build makes the journal old
    with _build, _Catch() as caught:
        context.install_listing_context(params)
        rows.run(-1, params)
    items = []
    for path, item, folder in caught.items[:MAX_ITEMS]:
        if not isinstance(item, Item):
            return None                               # made outside this build: its calls are unknown
        items.append([path, bool(folder), [[target, method, _pack(list(args)), _pack(kwargs)]
                                           for target, method, args, kwargs in item._calls]])
    return {'stamp': stamp(), 'revision': revision, 'content': caught.content, 'items': items}


def store(params, entry):
    """Keep the journal (Home property and file); older journals of the same slot beyond KEEP are dropped."""
    entry_name = name(params)
    text = json.dumps(entry, ensure_ascii=False, separators=(',', ':'))
    home = xbmcgui.Window(10000)
    home.setProperty(PROPERTY + entry_name, text)
    path = _path(entry_name)
    try:
        with open(path, encoding='utf-8') as handle:
            if handle.read() == text:
                return entry_name
    except OSError:
        pass
    os.makedirs(os.path.dirname(path), exist_ok=True)
    temporary = '{}.{}.tmp'.format(path, os.getpid())
    with open(temporary, 'w', encoding='utf-8') as handle:
        handle.write(text)
    os.replace(temporary, path)
    prefix = '{}.{}.'.format(params.get('sec', ''), params.get('slot', ''))
    older = sorted((entry for entry in os.scandir(os.path.dirname(path))
                    if entry.name.startswith(prefix) and entry.name.endswith('.json')),
                   key=lambda entry: entry.stat().st_mtime, reverse=True)[KEEP:]
    for entry_file in older:
        home.clearProperty(PROPERTY + entry_file.name[:-len('.json')])
        os.remove(entry_file.path)
    return entry_name


def prepare(params):
    """Build and keep the journal of one row; returns its name, or None (built by the plugin call instead).
    A row that fails to build keeps the journal it had."""
    params = normalize(params)
    started = time.perf_counter()
    try:
        entry = build(params)
    except Exception as exc:
        xbmc.log('[Milionář Skin] journal of row {}.{} {} failed: {}: {}'.format(
            params.get('sec'), params.get('slot'), params.get('row'), type(exc).__name__, exc), xbmc.LOGERROR)
        return None
    if entry is None:
        return None
    entry_name = store(params, entry)
    xbmc.log(PERF + 'prepared {}.{} {} {} cards in {:.0f} ms'.format(
        params.get('sec'), params.get('slot'), params.get('row'), len(entry['items']),
        (time.perf_counter() - started) * 1000), xbmc.LOGINFO)
    return entry_name


# --------------------------------------------------------------------------- reading a journal (plugin call)

def load(params):
    """The journal of exactly these parameters, this code and the row's current data, or None."""
    entry_name = name(params)
    text = xbmcgui.Window(10000).getProperty(PROPERTY + entry_name)
    if not text:
        try:
            with open(_path(entry_name), encoding='utf-8') as handle:
                text = handle.read()
        except OSError:
            return None
    try:
        entry = json.loads(text)
    except ValueError:
        return None
    if not isinstance(entry, dict) or entry.get('stamp') != stamp():
        return None
    return entry if entry.get('revision') == _revision(params.get('row', '')) else None


def exists(params):
    return load(normalize(params)) is not None


def replay(calls):
    item = None
    for target, method, args, kwargs in calls:
        if target == 'new':
            item = xbmcgui.ListItem(*_unpack(args), **_unpack(kwargs))
        else:
            owner = item if target == 'item' else getattr(item, TARGETS[target])()
            getattr(owner, method)(*_unpack(args), **_unpack(kwargs))
    return item


def serve(handle, params):
    """Answer a row call from its journal; False when there is none (the caller builds the row)."""
    started = time.perf_counter()
    if not (params.get('sec') and params.get('slot')):
        return False                                  # not a Home row
    entry = load(params)
    if entry is None:
        return False
    import xbmcplugin
    try:
        items = [(path, replay(calls), folder) for path, folder, calls in entry['items']]
    except Exception as exc:                           # a journal this Kodi cannot repeat: build the row instead
        xbmc.log('[Milionář Skin] journal of row {}.{} not usable: {}: {}'.format(
            params.get('sec'), params.get('slot'), type(exc).__name__, exc), xbmc.LOGWARNING)
        return False
    if entry.get('content'):
        xbmcplugin.setContent(handle, entry['content'])
    xbmcplugin.addDirectoryItems(handle, items, len(items))
    xbmcplugin.endOfDirectory(handle, succeeded=True, cacheToDisc=False)
    xbmc.log(PERF + 'row {}.{} {} served in {:.0f} ms'.format(
        params.get('sec'), params.get('slot'), params.get('row'), (time.perf_counter() - started) * 1000), xbmc.LOGINFO)
    return True


# --------------------------------------------------------------------------- keeping the journals complete (service)

def request_params(section, slot):
    """What Kodi puts into the URL of that Home row (gen_slots.CONTENT), or None for an unused slot."""
    def label(key):
        return xbmc.getInfoLabel('Skin.String(mil.{})'.format(key))
    row = label('{}.{}.type'.format(section, slot))
    if not row or row == 'off':
        return None
    get = lambda key: label('{}.{}.{}'.format(section, slot, key))
    return normalize({'action': 'widget_row', 'row': row, 'sec': section, 'slot': str(slot), 'limit': get('limit'),
                      'sort': get('sort'), 'style': get('style'), 'click': get('click'),
                      'hw': label(section + '.hidewatched'), 'up': label(section + '.upcoming'), 'reload': get('rev'),
                      'format': '2', 'profile': xbmcgui.Window(10000).getProperty('milionar.low_power'),
                      'hb': label(section + '.herorev'), 'mode': label(section + '.mode')})


def run(monitor):
    """Service thread: every row the Home screen can ask for has its journal (after an update, a new slot, another
    limit or mode). Changed data are prepared by wcs/skin/refresh.publish before the row is told to reload."""
    from wcs.skin import userfirst, warm
    from wcs.skin.hero_contract import WIDGET_SLOTS
    failed = {}                                       # journal name -> when it could not be prepared
    while not monitor.abortRequested():
        if family.active() and not xbmc.getCondVisibility('Player.HasMedia'):
            focused = warm.BY_MENU.get(xbmc.getInfoLabel('Container(9000).ListItem.Property(id)'))
            for section in sorted(warm.SECTIONS, key=lambda value: value != focused):
                if warm.hidden(section):
                    continue
                for slot in range(1, WIDGET_SLOTS + 1):
                    params = request_params(section, slot)
                    if not params or load(params) is not None:
                        continue
                    revision = _revision(params['row'])
                    if revision is not None and params.get('reload', '') != str(revision):
                        continue                              # changed data: refresh.publish prepares it and tells the row
                    if time.monotonic() - failed.get(name(params), -RETRY_AFTER) < RETRY_AFTER:
                        continue
                    if userfirst.give_way(monitor):          # the user started something: that first
                        return
                    if prepare(params) is None:
                        failed[name(params)] = time.monotonic()
                    if monitor.waitForAbort(0.02):           # the service's other threads get their turn
                        return
        if monitor.waitForAbort(3.0):
            return


def _guarded(monitor):
    try:
        run(monitor)
    except Exception as exc:
        xbmc.log('[Milionář Skin] journal thread stopped: {}: {}'.format(type(exc).__name__, exc), xbmc.LOGERROR)


def start(monitor):
    install()
    threading.Thread(target=_guarded, args=(monitor,), name='milionar-journal', daemon=True).start()
