# -*- coding: utf-8 -*-
"""Install / update the bundled Milionář skins and offer to switch to one (docs/plan/skin-milionar/040, 240).

skin.milionar ships in resources/skin.milionar; skin.milionar.modern is composed at install time from it and the
overlay resources/skin.milionar.modern (wcs/skin/compose.py). The service copies each to
special://home/addons/<id>, enables it (silently, over JSON-RPC) and asks once per addon version whether to switch
to the look chosen in the addon settings (`skin_variant`). A symlink at a target is development mode: never touched,
never offered.
"""
import json
import os
import shutil
import struct
import xml.etree.ElementTree as ET

import xbmc
import xbmcaddon
import xbmcgui
import xbmcvfs
from wcs.skin import compose, family

ADDON_ID = 'plugin.video.milionar'
SKIN_ID = family.CLASSIC
VARIANTS = family.SKINS                  # setting skin_variant: 0 classic, 1 modern
LOG_PREFIX = '[Milionář Skin]'

S_MESSAGE_SWITCH = 32232
S_MESSAGE_FAILED = 32233
GUI_WAIT_S = 60
ADDON_WAIT_S = 15
ESTUARY_DIR = 'special://xbmc/addons/skin.estuary/'
ESTUARY_ARCHIVE = ESTUARY_DIR + 'media/Textures.xbt'
TEXTURE_ARCHIVE = os.path.join('media', 'Textures.xbt')
# Shipped by Kodi itself, byte-identical to the development copies: filled in at install time, not downloaded.
ESTUARY_TREES = ('fonts', 'extras')
BRAND_FONT = os.path.join('fonts', 'NotoSans-MilionarBrand.ttf')      # same bytes as NotoSans-Regular.ttf
LOGO_COPIES = (os.path.join('media', 'milionar', 'logo.png'),
               os.path.join('media', 'milionar', 'logo-mark-v1-final.png'))
LOGO_SOURCE = os.path.join('media', 'brand', 'milionar-mark.png')    # relative to the plugin's resources/
RUNTIME_FILES = (os.path.join('fonts', 'NotoSans-Regular.ttf'), BRAND_FONT,
                 os.path.join('extras', 'backgrounds', 'primary.jpg')) + LOGO_COPIES


def _log(msg, level=xbmc.LOGINFO):
    xbmc.log('{} {}'.format(LOG_PREFIX, msg), level=level)


def _rpc(method, params=None):
    request = {'jsonrpc': '2.0', 'id': 1, 'method': method}
    if params is not None:
        request['params'] = params
    return json.loads(xbmc.executeJSONRPC(json.dumps(request)))


def bundled_dir(addon, skin_id=SKIN_ID):
    """The skin's source: the classic tree, or the modern overlay (composed with the classic one)."""
    return os.path.join(xbmcvfs.translatePath(addon.getAddonInfo('path')), 'resources', skin_id)


def target_dir(skin_id=SKIN_ID):
    return xbmcvfs.translatePath('special://home/addons/{}'.format(skin_id))


def read_version(folder):
    """version attribute of <folder>/addon.xml, or None."""
    try:
        return ET.parse(os.path.join(folder, 'addon.xml')).getroot().get('version')
    except (OSError, ET.ParseError):
        return None


def _staging_paths(skin_id=SKIN_ID):
    temp_root = xbmcvfs.translatePath('special://home/temp/')
    os.makedirs(temp_root, exist_ok=True)
    return (os.path.join(temp_root, skin_id + '.new'),
            os.path.join(temp_root, skin_id + '.old'))


def _validate_xbt(path):
    """Validate XBTF2 directory/frame bounds without unpacking image payloads.

    Layout follows Kodi 21 XBTFReader: 256-byte names and 40-byte frames.
    Reject truncation and payload offsets into the header or beyond the file.
    """
    with open(path, 'rb') as handle:
        size = os.fstat(handle.fileno()).st_size

        def read(length):
            value = handle.read(length)
            if len(value) != length:
                raise ValueError('truncated native texture archive')
            return value

        if read(5) != b'XBTF2':
            raise ValueError('invalid native texture archive signature')
        count, = struct.unpack('<I', read(4))
        if not count or count > max(0, size - 9) // 304:
            raise ValueError('invalid native texture file count')
        names, payloads = set(), []
        for _ in range(count):
            name = read(256).split(b'\0', 1)[0].lower()
            if not name or name in names:
                raise ValueError('empty or duplicate native texture name')
            names.add(name)
            _, frames = struct.unpack('<II', read(8))
            if not frames or frames > (size - handle.tell()) // 40:
                raise ValueError('invalid native texture frame count')
            for _ in range(frames):
                width, height, _, packed, unpacked, _, offset = struct.unpack('<IIIQQIQ', read(40))
                if not width or not height or not packed or not unpacked or packed > size - offset:
                    raise ValueError('invalid native texture frame')
                payloads.append((offset, packed))
        header_end = handle.tell()
        if any(offset < header_end for offset, _ in payloads):
            raise ValueError('native texture payload overlaps header')


def _validate_skin(folder, expected_version=None, require_archive=False, skin_id=SKIN_ID):
    """Check the manifest and essential declared runtime assets before a rename."""
    root = ET.parse(os.path.join(folder, 'addon.xml')).getroot()
    version = root.get('version')
    skin = root.find("extension[@point='xbmc.gui.skin']")
    if (root.tag != 'addon' or root.get('id') != skin_id or not version or
            skin is None or (expected_version is not None and version != expected_version)):
        raise ValueError('invalid skin manifest at {}'.format(folder))
    resolutions = skin.findall('res')
    if not resolutions:
        raise ValueError('skin manifest has no resolutions')
    paths = [os.path.join(res.get('folder', ''), filename)
             for res in resolutions for filename in ('Home.xml', 'Font.xml', 'Includes.xml')]
    paths.extend(asset.text or '' for asset in root.findall(
        "extension[@point='xbmc.addon.metadata']/assets/*") if asset.tag in ('icon', 'fanart'))
    for path in set(paths):
        if path == '../{}/resources/media/brand/milionar-mark.png'.format(ADDON_ID):
            shared = 'special://home/addons/{}/resources/media/brand/milionar-mark.png'.format(ADDON_ID)
            if not os.path.isfile(xbmcvfs.translatePath(shared)):
                raise ValueError('missing shared skin logo: {}'.format(path))
            continue
        if (not path or os.path.isabs(path) or '..' in path.replace('\\', '/').split('/') or
                not os.path.isfile(os.path.join(folder, path))):
            raise ValueError('missing or invalid skin asset: {}'.format(path))
    if require_archive:
        _validate_xbt(os.path.join(folder, TEXTURE_ARCHIVE))
        for relative in RUNTIME_FILES:
            if not os.path.isfile(os.path.join(folder, relative)):
                raise ValueError('missing skin runtime file: {}'.format(relative))
    return version


def _valid_skin(folder, require_archive=False, skin_id=SKIN_ID):
    try:
        return _validate_skin(folder, require_archive=require_archive, skin_id=skin_id)
    except (OSError, ET.ParseError, ValueError):
        return None


def _remove_tree(folder):
    if os.path.islink(folder) or (os.path.lexists(folder) and not os.path.isdir(folder)):
        os.unlink(folder)
    elif os.path.isdir(folder):
        shutil.rmtree(folder)


def _recover_swap(target, skin_id=SKIN_ID):
    """Legacy targets/backups remain recoverable; fresh stages require native XBT."""
    staged, retired = _staging_paths(skin_id)
    if _valid_skin(target, skin_id=skin_id):
        return False
    for candidate, require_archive in ((retired, False), (staged, True)):
        if _valid_skin(candidate, require_archive=require_archive, skin_id=skin_id):
            _remove_tree(target)
            os.rename(candidate, target)
            _log('recovered {} from {}'.format(skin_id, candidate))
            return True
    return False


class _InstallAborted(Exception):
    pass


def _check_abort(monitor):
    if monitor is not None and monitor.abortRequested():
        raise _InstallAborted()


def _estuary_files():
    """Relative paths of every file in the system Estuary fonts/ and extras/ folders."""
    found = []

    def walk(relative):
        directories, names = xbmcvfs.listdir(ESTUARY_DIR + relative.replace(os.sep, '/') + '/')
        found.extend(os.path.join(relative, name) for name in names)
        for name in directories:
            walk(os.path.join(relative, name))

    for tree in ESTUARY_TREES:
        walk(tree)
    return found


def _plan(source, skin_id):
    """{relative path: (source path, transform)} of the skin to stage; the modern one is composed over the classic."""
    if skin_id == family.MODERN:
        return compose.plan(os.path.join(os.path.dirname(source), family.CLASSIC), source)
    return compose.plan(source)


def _copy_runtime(entry, staged, source):
    """Copy one runtime file that is not part of the ZIP: from system Estuary, or the plugin's logo."""
    kind, relative = entry
    if kind == 'estuary':
        destination = os.path.join(staged, relative)
        os.makedirs(os.path.dirname(destination), exist_ok=True)
        if not xbmcvfs.copy(ESTUARY_DIR + relative.replace(os.sep, '/'), destination):
            raise OSError('could not copy system Estuary file: {}'.format(relative))
        if relative == os.path.join('fonts', 'NotoSans-Regular.ttf'):
            shutil.copyfile(destination, os.path.join(staged, BRAND_FONT))
        return
    logo = os.path.join(os.path.dirname(source), 'media', 'brand', 'milionar-mark.png')
    for copy in LOGO_COPIES:
        destination = os.path.join(staged, copy)
        os.makedirs(os.path.dirname(destination), exist_ok=True)
        shutil.copyfile(logo, destination)


def _stage_skin(source, staged, monitor, progress, skin_id=SKIN_ID):
    """One source walk; copy bytes without unnecessary file timestamp/mode writes.

    addon.xml is copied last: an interrupted stage cannot look installable.
    Percentages count files, not bytes, and leave room for validation/registration.
    """
    _check_abort(monitor)
    planned = _plan(source, skin_id)
    files = [relative for relative in planned if relative not in ('addon.xml', TEXTURE_ARCHIVE)]
    estuary = _estuary_files()
    files.append(TEXTURE_ARCHIVE)
    files.extend(('estuary', relative) for relative in estuary)
    files.append(('logo', None))
    files.append('addon.xml')
    for relative in {os.path.dirname(relative) for relative in planned}:
        os.makedirs(os.path.join(staged, relative), exist_ok=True)
    classic = os.path.join(os.path.dirname(source), family.CLASSIC) if skin_id == family.MODERN else source
    previous = 0
    for index, relative in enumerate(files, 1):
        _check_abort(monitor)
        if isinstance(relative, tuple):
            _copy_runtime(relative, staged, classic)
        elif relative == TEXTURE_ARCHIVE:
            os.makedirs(os.path.join(staged, 'media'), exist_ok=True)
            destination = os.path.join(staged, relative)
            if not xbmcvfs.copy(ESTUARY_ARCHIVE, destination):
                raise OSError('could not copy system Estuary texture archive')
            _validate_xbt(destination)
        elif planned[relative][1] is None:
            shutil.copyfile(planned[relative][0], os.path.join(staged, relative))
        else:
            with open(os.path.join(staged, relative), 'wb') as handle:
                handle.write(compose.read(planned[relative]))
        percent = index * 90 // len(files)
        if progress is not None and percent != previous:
            progress.update(percent)
            previous = percent


def _swap_in(source, target, monitor=None, progress=None, skin_id=SKIN_ID):
    """Stage outside addons/, validate, then rename; retain rollback until the next update."""
    staged, retired = _staging_paths(skin_id)
    _check_abort(monitor)
    _recover_swap(target, skin_id)
    _remove_tree(staged)
    _stage_skin(source, staged, monitor, progress, skin_id)
    if progress is not None:
        progress.update(92, message=xbmc.getLocalizedString(24077))
    _validate_skin(staged, read_version(source), require_archive=True, skin_id=skin_id)
    _check_abort(monitor)
    # Only discard an earlier backup when another valid installed target exists.
    if os.path.lexists(retired):
        if _valid_skin(retired, skin_id=skin_id) and not _valid_skin(target, skin_id=skin_id):
            raise ValueError('cannot discard backup without a valid installed skin')
        _remove_tree(retired)
    _check_abort(monitor)
    if os.path.lexists(target):
        os.rename(target, retired)
    try:
        os.rename(staged, target)
    except OSError:
        if os.path.lexists(retired):
            os.rename(retired, target)  # roll back: the old skin stays
        raise
    # No recursive deletion on the completion path; .old is outside Kodi's scan.
    if progress is not None:
        progress.update(95, message=xbmc.getLocalizedString(24086))


def _skin_known(skin_id=SKIN_ID):
    response = _rpc('Addons.GetAddonDetails', {'addonid': skin_id, 'properties': ['enabled']})
    return response.get('result', {}).get('addon')


def _wait_known(monitor, timeout_s, skin_id=SKIN_ID):
    waited = 0.0
    while waited < timeout_s:
        details = _skin_known(skin_id)
        if details is not None:
            return details
        if monitor.waitForAbort(0.5):
            return None
        waited += 0.5
    return None


def install(addon, monitor=None, skin_id=SKIN_ID):
    """Bring the installed skin `skin_id` in line with the bundled one.

    Returns {'dev': bool, 'copied': bool, 'ready': bool, 'version': str|None}.
    """
    monitor = monitor or xbmc.Monitor()
    install_keymap(addon)
    source, target = bundled_dir(addon, skin_id), target_dir(skin_id)
    bundled = read_version(source)
    result = {'dev': False, 'copied': False, 'ready': False, 'version': bundled}
    installed = None
    if os.path.islink(target):
        result['dev'] = True
        _log('dev mode: {} is a symlink, not copying'.format(target))
    else:
        installed = _valid_skin(target, require_archive=True, skin_id=skin_id)
        if installed is None or installed != bundled:
            result['copied'] = _recover_swap(target, skin_id)
            if result['copied']:
                installed = _valid_skin(target, require_archive=True, skin_id=skin_id)
    if bundled is None:
        _log('bundled skin missing at {}'.format(source), xbmc.LOGERROR)
        return result
    progress = None
    try:
        if not result['dev'] and installed != bundled:
            _check_abort(monitor)
            _log('installing {} {} (installed: {})'.format(skin_id, bundled, read_version(target)))
            # Kodi 21.3's native progress handle synchronizes its own state.
            # No WindowXML/Control calls from the service worker. This phase starts
            # after core ZIP installation has reloaded the addon, never during unload.
            progress = xbmcgui.DialogProgressBG()
            progress.create('Milionář · {}'.format(skin_id), xbmc.getLocalizedString(33009))
            _swap_in(source, target, monitor, progress, skin_id)
            result['copied'] = True
            if skin_id == SKIN_ID:
                addon.setSetting('skin_installed_version', bundled)
        if result['copied'] or _skin_known(skin_id) is None:
            xbmc.executebuiltin('UpdateLocalAddons')
        details = _wait_known(monitor, ADDON_WAIT_S, skin_id)
        if details is None:
            _log('Kodi did not register {} within {} s'.format(skin_id, ADDON_WAIT_S), xbmc.LOGWARNING)
            return result
        if not details.get('enabled'):
            response = _rpc('Addons.SetAddonEnabled', {'addonid': skin_id, 'enabled': True})
            if 'error' in response:
                _log('could not enable {}: {}'.format(skin_id, response['error']), xbmc.LOGERROR)
                return result
            _log('enabled {}'.format(skin_id))
        result['ready'] = True
        return result
    except _InstallAborted:
        _log('skin staging aborted; installed skin retained')
        return result
    finally:
        if progress is not None:
            try:
                if result['ready']:
                    progress.update(100, message=xbmc.getLocalizedString(24084))
            finally:
                progress.close()
            if not monitor.abortRequested():
                message = (xbmc.getLocalizedString(24084) if result['ready'] else
                           addon.getLocalizedString(S_MESSAGE_FAILED))
                icon = xbmcgui.NOTIFICATION_INFO if result['ready'] else xbmcgui.NOTIFICATION_ERROR
                xbmcgui.Dialog().notification(skin_id, message, icon, 5000, False)


def install_keymap(addon):
    source = os.path.join(xbmcvfs.translatePath(addon.getAddonInfo('path')), 'resources', 'keymaps', 'milionar-home.xml')
    target = xbmcvfs.translatePath('special://profile/keymaps/milionar-home.xml')
    with open(source, 'rb') as handle:
        content = handle.read()
    try:
        with open(target, 'rb') as handle:
            if handle.read() == content:
                return
    except FileNotFoundError:
        pass
    os.makedirs(os.path.dirname(target), exist_ok=True)
    with open(target, 'wb') as handle:
        handle.write(content)
    xbmc.executebuiltin('Action(reloadkeymaps)')


def chosen(addon):
    """The Milionář skin chosen in the addon settings (skin_variant: 0 classic, 1 modern)."""
    try:
        return VARIANTS[int(addon.getSetting('skin_variant') or 0)]
    except (ValueError, TypeError, IndexError):
        return SKIN_ID


def _settings_file(skin_id):
    return xbmcvfs.translatePath('special://profile/addon_data/{}/settings.xml'.format(skin_id))


def _carry_settings(source_id, target_id, keep=False):
    """Kodi keeps skin settings per skin: the Home layout and switches of one Milionář look go along to the other.
    `keep`: only when the target has none yet."""
    source, target = _settings_file(source_id), _settings_file(target_id)
    if not os.path.isfile(source) or (keep and os.path.isfile(target)):
        return
    os.makedirs(os.path.dirname(target), exist_ok=True)
    shutil.copyfile(source, target)
    _log('skin settings carried from {} to {}'.format(source_id, target_id))


def switch(addon, skin_id=None):
    """Make a Milionář skin (the chosen one by default) the active skin; Kodi then asks "Keep this skin?" itself."""
    skin_id = skin_id or chosen(addon)
    info = install(addon, skin_id=skin_id)
    if not info['ready']:
        xbmcgui.Dialog().notification('Milionář', addon.getLocalizedString(S_MESSAGE_FAILED),
                                      xbmcgui.NOTIFICATION_ERROR, 5000)
        return False
    active = xbmc.getSkinDir()
    if active == skin_id:
        return True
    if active in VARIANTS:
        _carry_settings(active, skin_id)
    response = _rpc('Settings.SetSettingValue', {'setting': 'lookandfeel.skin', 'value': skin_id})
    _log('switch to {} requested: {}'.format(skin_id, response.get('result', response.get('error'))))
    return 'error' not in response


def apply_variant(addon=None):
    """The addon settings changed: a new choice of look switches a running Milionář skin to it."""
    addon = addon or xbmcaddon.Addon(ADDON_ID)
    wanted = chosen(addon)
    if addon.getSetting('skin_variant_applied') == wanted:
        return
    addon.setSetting('skin_variant_applied', wanted)
    if family.active() and xbmc.getSkinDir() != wanted:
        switch(addon, wanted)


def _wait_for_quiet_gui(monitor):
    """Offer only on quiet Home; migration has priority over the optional switch."""
    waited = 0.0
    while not monitor.abortRequested():
        if (xbmc.getCondVisibility('Window.IsActive(home)') and
                not xbmc.getCondVisibility('Player.HasVideo') and
                not xbmc.getCondVisibility('System.HasActiveModalDialog') and
                xbmcgui.Window(10000).getProperty('milionar.migration.pending') not in ('1', 'true')):
            return True
        if monitor.waitForAbort(2):
            return False
        waited += 2
        if waited == GUI_WAIT_S:
            _log('still waiting for Home / end of playback')
    return False


def _install_each(addon, monitor):
    """Install every Milionář skin; one that fails leaves the others working."""
    results = {}
    for skin_id in VARIANTS:
        try:
            results[skin_id] = install(addon, monitor, skin_id)
        except Exception as exc:
            _log('{} failed: {}: {}'.format(skin_id, type(exc).__name__, exc), xbmc.LOGERROR)
            results[skin_id] = {'dev': False, 'copied': False, 'ready': False, 'version': None}
    if results[family.MODERN]['copied']:
        _carry_settings(family.CLASSIC, family.MODERN, keep=True)   # chosen in Kodi's own skin list it looks the same
    return results


def run(addon=None):
    """Service entry point: install/update, refresh a running skin, offer the switch."""
    try:
        addon = addon or xbmcaddon.Addon(ADDON_ID)
        monitor = xbmc.Monitor()
        results = _install_each(addon, monitor)
        if addon.getSetting('skin_variant_applied') != chosen(addon):
            addon.setSetting('skin_variant_applied', chosen(addon))   # only a later change switches (apply_variant)
        active = xbmc.getSkinDir()
        if active in results:
            if results[active]['copied']:
                _log('skin updated while active: ReloadSkin()')
                xbmc.executebuiltin('ReloadSkin()')
            xbmcgui.Window(10000).clearProperty('milionar.skin.installing')
            return
        # The service's migration gate waits for disk installation, not the
        # optional switch offer (which may wait indefinitely for Home).
        xbmcgui.Window(10000).clearProperty('milionar.skin.installing')
        info = results[chosen(addon)]
        if not info['ready'] or info['dev']:
            return  # development: the harness switches skins without a question
        addon_version = addon.getAddonInfo('version')
        if addon.getSetting('skin_offer_version') == addon_version:
            return
        if not _wait_for_quiet_gui(monitor):
            return
        if xbmcgui.Dialog().yesno('Milionář', addon.getLocalizedString(S_MESSAGE_SWITCH)):
            addon.setSetting('skin_offer_version', addon_version)
            switch(addon)
        else:
            addon.setSetting('skin_offer_version', addon_version)
            _log('switch declined for addon version {}'.format(addon_version))
    except Exception as exc:  # the addon must not break because the skin failed
        _log('installer failed: {}: {}'.format(type(exc).__name__, exc), xbmc.LOGERROR)
