# -*- coding: utf-8 -*-
"""Bytecode that Kodi does not make itself (docs/plan/skin-optimization, part E).

Kodi's Python never writes bytecode (`sys.dont_write_bytecode`), so a script start compiles every module it imports
again: this addon's and those of the Python libraries it needs (`requests` and what that pulls in is the largest
part). The release ZIP ships bytecode for the Python of Kodi 21. This service thread adds what is missing on this
device, once per version of the addon and of each library:

  * the addon itself, when the device's Python is another version than the shipped bytecode is for,
  * the libraries of the `script.module.*` addons this addon depends on, in their own folders.

Every file is hash-checked: Python compares it with its source on every import and ignores one that does not
belong to it. A stale file can therefore never run; the worst case is that a module is compiled as before.
A folder that cannot be written (a system addon) is left alone.
"""
import importlib.util
import json
import os
import py_compile
import sys
import threading
import xml.etree.ElementTree as ET

import xbmc
import xbmcaddon
import xbmcvfs
from wcs.skin import userfirst

ADDON_ID = 'plugin.video.milionar'
MARKER = 'special://profile/addon_data/plugin.video.milionar/bytecode.json'
FIRST_DELAY = 90                         # seconds after the start: Home and its rows come first
CHECKED = b'\x03\x00\x00\x00'            # flags of a hash-checked file


def _log(message, level=xbmc.LOGINFO):
    xbmc.log('[Milionář Skin] bytecode: ' + message, level)


def valid(path, cached):
    """`cached` is hash-checked bytecode of this Python for the source `path`."""
    try:
        with open(cached, 'rb') as handle:
            header = handle.read(16)
        with open(path, 'rb') as handle:
            source = handle.read()
    except OSError:
        return False
    return (header[:4] == importlib.util.MAGIC_NUMBER and header[4:8] == CHECKED
            and header[8:16] == importlib.util.source_hash(source))


def compile_tree(root, pause=None):
    """Make the missing bytecode under `root` (a folder or one .py file): (made, kept, failed)."""
    made = kept = failed = 0
    if os.path.isfile(root):
        files = [root]
    else:
        files = [os.path.join(folder, name) for folder, directories, names in os.walk(root)
                 for name in names if name.endswith('.py') and '__pycache__' not in folder]
    for path in sorted(files):
        cached = importlib.util.cache_from_source(path)
        if valid(path, cached):
            kept += 1
            continue
        try:
            py_compile.compile(path, cfile=cached, doraise=True,
                               invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH)
            made += 1
        except Exception:                             # not writable, or code this Python cannot compile: as before
            failed += 1
        if pause and pause():
            break
    return made, kept, failed


def libraries(addon_path):
    """Paths of the `script.module.*` addons the addon needs, with those they need: {addon id: (version, lib folder)}."""
    found, queue = {}, [addon_path]
    while queue:
        try:
            requires = ET.parse(os.path.join(queue.pop(), 'addon.xml')).getroot().find('requires')
        except (OSError, ET.ParseError):
            continue
        for node in (requires.findall('import') if requires is not None else []):
            name = node.get('addon', '')
            if not name.startswith('script.module.') or name in found:
                continue
            try:
                addon = xbmcaddon.Addon(name)
                path = xbmcvfs.translatePath(addon.getAddonInfo('path'))
                found[name] = (addon.getAddonInfo('version'), os.path.join(path, 'lib'))
                queue.append(path)
            except Exception:                         # not installed
                continue
    return found


def state(addon_path):
    """What the bytecode on this device was made for."""
    addon = xbmcaddon.Addon(ADDON_ID)
    return {'python': sys.implementation.cache_tag, 'magic': importlib.util.MAGIC_NUMBER.hex(),
            'addons': dict({ADDON_ID: addon.getAddonInfo('version')},
                           **{name: version for name, (version, _lib) in libraries(addon_path).items()})}


def run(monitor):
    """Service thread."""
    if monitor.waitForAbort(FIRST_DELAY):
        return
    addon_path = xbmcvfs.translatePath(xbmcaddon.Addon(ADDON_ID).getAddonInfo('path'))
    marker = xbmcvfs.translatePath(MARKER)
    current = state(addon_path)
    try:
        with open(marker, encoding='utf-8') as handle:
            if json.load(handle) == current:
                return                                # nothing was installed or updated since
    except (OSError, ValueError):
        pass

    def pause():
        while xbmc.getCondVisibility('Player.HasMedia'):      # never while something plays
            if monitor.waitForAbort(5):
                return True
        if userfirst.give_way(monitor):                       # nor while the user starts something
            return True
        return monitor.waitForAbort(0.02)

    totals = [0, 0, 0]
    roots = [os.path.join(addon_path, 'wcs'), os.path.join(addon_path, 'md5crypt.py')]
    roots += [lib for _version, lib in libraries(addon_path).values()]
    for root in roots:
        if monitor.abortRequested():
            return                                    # not finished: no marker, the next start continues
        if os.path.exists(root):
            for index, value in enumerate(compile_tree(root, pause)):
                totals[index] += value
    os.makedirs(os.path.dirname(marker), exist_ok=True)
    with open(marker, 'w', encoding='utf-8') as handle:
        json.dump(current, handle)
    _log('{} made, {} already there, {} left as they are ({})'.format(totals[0], totals[1], totals[2], current['python']))


def _guarded(monitor):
    try:
        run(monitor)
    except Exception as exc:
        _log('{}: {}'.format(type(exc).__name__, exc), xbmc.LOGWARNING)


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