# -*- coding: utf-8 -*-
"""Live dialog harness -- local command channel inside the running Kodi.

The agent-side CLI (scripts/dialog_harness/kodi_live.py) writes a JSON request
into the harness directory; this listener (started from service.py) executes
it inside Kodi and writes a JSON response. Nothing runs unless the harness
directory contains the ENABLED flag file, which only the local CLI creates.

Directory: special://profile/addon_data/plugin.video.milionar/harness/
  ENABLED            flag file, created by `kodi_live.py enable`
  request.json       one pending request {"id": str, "ops": [...]}
  response-<id>.json result written after all ops ran
  heartbeat          touched while the listener is alive

Supported ops (executed in order, each returns a result dict):
  {"op": "ping"}
  {"op": "info", "labels": ["System.CurrentWindow", ...], "conds": ["Window.IsActive(home)"]}
  {"op": "builtin", "cmd": "ActivateWindow(Videos,plugin://...,return)", "wait": false}
  {"op": "sleep", "ms": 500}
  {"op": "wait", "cond": "Window.IsActive(...)", "timeout_ms": 8000}
  {"op": "screenshot", "path": "/abs/out.png"}
  {"op": "jsonrpc", "request": {"jsonrpc": "2.0", "method": "...", "id": 1}}
  {"op": "mark", "token": "unique"}                 writes the token to kodi.log
  {"op": "skin", "name": "skin.estuary"}            switch skin, confirm "keep skin"
  {"op": "reload", "timeout_ms": 20000}             ReloadSkin(), wait for Home
  {"op": "addon_setting", "key": "dev_fixture_provider", "value": "true"}   set an addon setting
  {"op": "call", "module": "wcs.library2.fixture_provider", "func": "seed_meta", "args": []}   call any addon function
  {"op": "stddialog", "kind": "yesno|select|ok|textviewer|input|numeric|progressbg|notification",
   "wait_cond": "Window.IsActive(yesnodialog)"}     open a standard Kodi dialog (own thread)
  {"op": "dialog", "xml": "notifications/premium/X.xml", "props": {...},
   "items": {"50": [{"Label": "...", "prop": "..."}]}, "focus": 111,
   "settle_ms": 1200, "screenshot": "/abs/out.png", "close": true, "measure": [ids],
   "backdrop": {"image": "/abs/backdrop.png"},
   "python_class": "wcs.ui.dialogs.DialogWcsSearch:DialogWcsSearch", "kwargs": {...}}
"""

import json
import os
import time
import traceback

import xbmc
import xbmcaddon
import xbmcgui
import xbmcvfs

ADDON_ID = 'plugin.video.milionar'
HARNESS_DIR = 'special://profile/addon_data/{}/harness/'.format(ADDON_ID)
IDLE_POLL_S = 2.0
_KEPT = []  # dialogs kept open by "close": false, closed by the next dialog op or "close_kept"
ACTIVE_POLL_S = 0.25


def _dir():
    return xbmcvfs.translatePath(HARNESS_DIR)


def _log(msg, level=xbmc.LOGINFO):
    xbmc.log('[Milionář Harness] {}'.format(msg), level=level)


class _FixtureDialog(xbmcgui.WindowXMLDialog):
    """Generic dialog used to show any addon XML with fixture properties."""

    def __init__(self, *args, **kwargs):
        self.fixture_props = kwargs.pop('fixture_props', {})
        self.fixture_items = kwargs.pop('fixture_items', {})
        self.fixture_focus = kwargs.pop('fixture_focus', None)
        super().__init__(*args)

    def onInit(self):
        for key, value in self.fixture_props.items():
            self.setProperty(key, str(value))
        for control_id, items in self.fixture_items.items():
            try:
                control = self.getControl(int(control_id))
            except (RuntimeError, TypeError, ValueError):
                continue
            list_items = []
            for data in items:
                item = xbmcgui.ListItem(label=str(data.get('Label', '')))
                art = {}
                for key, value in data.items():
                    if key == 'Label':
                        continue
                    if key in ('thumb', 'poster', 'fanart', 'icon'):
                        art[key] = str(value)
                    item.setProperty(key, str(value))
                if art:
                    item.setArt(art)
                list_items.append(item)
            try:
                control.reset()
                control.addItems(list_items)
            except (RuntimeError, AttributeError):
                pass
        if self.fixture_focus is not None:
            try:
                self.setFocusId(int(self.fixture_focus))
            except (RuntimeError, TypeError, ValueError):
                pass

    def onAction(self, action):
        if action.getId() in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU):
            self.close()


def _screen_info():
    return {
        'screen_w': xbmcgui.getScreenWidth(),
        'screen_h': xbmcgui.getScreenHeight(),
        'resolution': xbmc.getInfoLabel('System.ScreenResolution'),
        'current_window': xbmc.getInfoLabel('System.CurrentWindow'),
        'current_window_id': xbmcgui.getCurrentWindowId(),
        'current_dialog_id': xbmcgui.getCurrentWindowDialogId(),
    }


def _take_screenshot(path, timeout_s=10.0):
    folder = os.path.dirname(path)
    if folder and not os.path.isdir(folder):
        os.makedirs(folder)
    if os.path.exists(path):
        os.remove(path)
    xbmc.executebuiltin('TakeScreenshot({},sync)'.format(path), True)
    deadline = time.time() + timeout_s
    last_size = -1
    while time.time() < deadline:
        if os.path.exists(path):
            size = os.path.getsize(path)
            if size > 0 and size == last_size:
                return {'path': path, 'bytes': size}
            last_size = size
        xbmc.sleep(100)
    raise RuntimeError('screenshot not written: {}'.format(path))


def _wait_cond(cond, timeout_ms):
    deadline = time.time() + timeout_ms / 1000.0
    while time.time() < deadline:
        if xbmc.getCondVisibility(cond):
            return True
        xbmc.sleep(100)
    return False


def _op_skin(op):
    """Switch the skin and press Yes on the "keep this skin" dialog (button id 11)."""
    name = op['name']
    if xbmc.getSkinDir() == name:
        return {'skin': name, 'changed': False}
    response = json.loads(xbmc.executeJSONRPC(json.dumps({
        'jsonrpc': '2.0', 'id': 1, 'method': 'Settings.SetSettingValue',
        'params': {'setting': 'lookandfeel.skin', 'value': name}})))
    if 'error' in response:
        raise RuntimeError('SetSettingValue failed: {}'.format(response['error']))
    deadline = time.time() + int(op.get('timeout_ms', 30000)) / 1000.0
    confirmed = False
    while time.time() < deadline:
        if not confirmed and xbmc.getCondVisibility('Window.IsActive(yesnodialog)'):
            xbmc.sleep(300)
            xbmc.executebuiltin('SendClick(11)', True)
            confirmed = True
        if xbmc.getSkinDir() == name and xbmc.getCondVisibility('Window.IsActive(home)') \
                and not xbmc.getCondVisibility('Window.IsActive(yesnodialog)'):
            xbmc.sleep(int(op.get('settle_ms', 1500)))
            return {'skin': name, 'changed': True, 'confirmed': confirmed}
        xbmc.sleep(200)
    raise RuntimeError('skin {} not active after switch (active: {})'.format(name, xbmc.getSkinDir()))


_STD_KEEP = []  # progress dialogs must stay referenced while shown


def _run_std_dialog(kind):
    dialog = xbmcgui.Dialog()
    if kind == 'yesno':
        dialog.yesno('Harness', 'Do you want to continue with this fixture dialog?')
    elif kind == 'select':
        dialog.select('Harness', ['First option', 'Second option', 'Third option', 'Fourth option'])
    elif kind == 'ok':
        dialog.ok('Harness', 'A fixture message for the OK dialog.')
    elif kind == 'textviewer':
        dialog.textviewer('Harness', 'Line one\nLine two\nLine three\n' * 12)
    elif kind == 'input':
        dialog.input('Harness', 'fixture text')
    elif kind == 'numeric':
        dialog.numeric(0, 'Harness')
    elif kind == 'notification':
        dialog.notification('Harness', 'A fixture notification message', xbmcgui.NOTIFICATION_INFO, 6000)
    elif kind == 'progressbg':
        progress = xbmcgui.DialogProgressBG()
        progress.create('Harness', 'Fixture progress message')
        progress.update(40, 'Harness', 'Fixture progress message')
        _STD_KEEP.append(progress)
    else:
        raise ValueError('unknown dialog kind: {}'.format(kind))


def _op_stddialog(op):
    import threading
    kind = op['kind']
    for progress in _STD_KEEP:
        try:
            progress.close()
        except RuntimeError:
            pass
    del _STD_KEEP[:]
    if kind == 'close':
        return {'kind': kind}
    thread = threading.Thread(target=_run_std_dialog, args=(kind,), daemon=True)
    thread.start()
    met = _wait_cond(op['wait_cond'], int(op.get('timeout_ms', 8000))) if op.get('wait_cond') else True
    xbmc.sleep(int(op.get('settle_ms', 800)))
    return {'kind': kind, 'met': met}


def _open_backdrop(addon, backdrop):
    """Show a neutral full-screen backdrop so translucent dialogs capture cleanly."""
    props = {'Harness.Image': backdrop['image']}
    shade = _FixtureDialog('devtools/HarnessBackdrop.xml', addon.getAddonInfo('path'),
                           'Default', '1080i', fixture_props=props)
    shade.show()
    xbmc.sleep(int(backdrop.get('settle_ms', 400)))
    return shade


def _measure(window, control_ids):
    """Real control geometry as Kodi laid it out (x/y relative to the parent group)."""
    rects = {}
    for control_id in control_ids:
        try:
            control = window.getControl(int(control_id))
            rects[str(control_id)] = {'x': control.getX(), 'y': control.getY(),
                                      'w': control.getWidth(), 'h': control.getHeight()}
        except (RuntimeError, TypeError, ValueError) as exc:
            rects[str(control_id)] = {'error': str(exc)}
    return rects


def _close_kept():
    while _KEPT:
        window = _KEPT.pop()
        try:
            window.close()
        except RuntimeError:
            pass
    xbmc.sleep(200)


def _op_dialog(op):
    _close_kept()
    addon = xbmcaddon.Addon(ADDON_ID)
    shade = _open_backdrop(addon, op['backdrop']) if op.get('backdrop') else None
    if op.get('python_class'):
        # Real dialog class with fixture kwargs: its own onInit logic runs as in production
        import importlib
        module_name, class_name = op['python_class'].split(':')
        base_class = getattr(importlib.import_module(module_name), class_name)
        trace = []

        class _Traced(base_class):
            """Records whether the real onInit ran and what it raised."""
            def onInit(self):
                trace.append('onInit')
                try:
                    base_class.onInit(self)
                    trace.append('onInit done')
                except Exception:
                    trace.append('onInit raised: ' + traceback.format_exc())

        dialog = _Traced(op['xml'], addon.getAddonInfo('path'), **op.get('kwargs', {}))
    else:
        dialog = _FixtureDialog(
            op['xml'], addon.getAddonInfo('path'), 'Default', '1080i',
            fixture_props=op.get('props', {}),
            fixture_items=op.get('items', {}),
            fixture_focus=op.get('focus'),
        )
    result = {}
    dialog.show()
    try:
        xbmc.sleep(int(op.get('settle_ms', 1200)))
        if op.get('focus') is not None:
            dialog.setFocusId(int(op['focus']))
            xbmc.sleep(int(op.get('focus_settle_ms', 400)))
        result['focused_id'] = dialog.getFocusId() if hasattr(dialog, 'getFocusId') else None
        if op.get('measure'):
            result['measured'] = _measure(dialog, op['measure'])
        if op.get('python_class'):
            result['class_trace'] = trace
        if op.get('screenshot'):
            result['screenshot'] = _take_screenshot(op['screenshot'])
    finally:
        if op.get('close', True):
            dialog.close()
            if shade is not None:
                shade.close()
            xbmc.sleep(300)
            del dialog
        else:  # keep a reference, otherwise the window dies with this function
            _KEPT.extend([w for w in (dialog, shade) if w is not None])
    return result


def _run_op(op):
    kind = op.get('op')
    if kind == 'ping':
        info = _screen_info()
        info['kodi'] = xbmc.getInfoLabel('System.BuildVersion')
        info['skin'] = xbmc.getSkinDir()
        return info
    if kind == 'info':
        return {
            'labels': {label: xbmc.getInfoLabel(label) for label in op.get('labels', [])},
            'conds': {cond: bool(xbmc.getCondVisibility(cond)) for cond in op.get('conds', [])},
            'screen': _screen_info(),
        }
    if kind == 'builtin':
        xbmc.executebuiltin(op['cmd'], bool(op.get('wait', False)))
        return {'cmd': op['cmd']}
    if kind == 'sleep':
        xbmc.sleep(int(op.get('ms', 500)))
        return {}
    if kind == 'wait':
        deadline = time.time() + int(op.get('timeout_ms', 8000)) / 1000.0
        while time.time() < deadline:
            if xbmc.getCondVisibility(op['cond']):
                return {'met': True}
            xbmc.sleep(100)
        return {'met': False}
    if kind == 'jsonrpc':
        started = time.perf_counter()
        result = json.loads(xbmc.executeJSONRPC(json.dumps(op['request'])))
        result['elapsed_ms'] = round((time.perf_counter() - started) * 1000, 1)
        return result
    if kind == 'mark':
        xbmc.log('[Milionář Harness] MARK {}'.format(op['token']), level=xbmc.LOGINFO)
        return {'token': op['token']}
    if kind == 'skin':
        return _op_skin(op)
    if kind == 'reload':
        xbmc.executebuiltin('ReloadSkin()', True)
        xbmc.sleep(500)
        met = _wait_cond('Window.IsActive(home)', int(op.get('timeout_ms', 20000)))
        xbmc.sleep(int(op.get('settle_ms', 1000)))
        return {'home': met, 'skin': xbmc.getSkinDir()}
    if kind == 'stddialog':
        return _op_stddialog(op)
    if kind == 'addon_setting':
        xbmcaddon.Addon(ADDON_ID).setSetting(op['key'], str(op['value']))
        return {'key': op['key'], 'value': xbmcaddon.Addon(ADDON_ID).getSetting(op['key'])}
    if kind == 'call':
        import importlib
        func = getattr(importlib.import_module(op['module']), op['func'])
        result = func(*op.get('args', []))
        return {'result': result if isinstance(result, (int, float, str, bool, list, dict, type(None))) else str(result)}
    if kind == 'close_kept':
        _close_kept()
        return {}
    if kind == 'screenshot':
        result = _take_screenshot(op['path'])
        result.update(_screen_info())
        return result
    if kind == 'dialog':
        result = _op_dialog(op)
        result.update(_screen_info())
        return result
    raise ValueError('unknown op: {}'.format(kind))


def _process(request_path, base):
    processing = request_path + '.processing'
    os.replace(request_path, processing)
    try:
        with open(processing, 'r', encoding='utf-8') as handle:
            request = json.load(handle)
    except (OSError, ValueError) as exc:
        _log('invalid request: {}'.format(exc), xbmc.LOGWARNING)
        os.remove(processing)
        return
    request_id = str(request.get('id', 'noid'))
    results, error = [], None
    for op in request.get('ops', []):
        try:
            results.append({'op': op.get('op'), 'ok': True, 'result': _run_op(op)})
        except Exception as exc:  # report every failure back to the CLI
            error = '{}: {}'.format(type(exc).__name__, exc)
            results.append({'op': op.get('op'), 'ok': False, 'error': error,
                            'trace': traceback.format_exc()})
            _log('op {} failed: {}'.format(op.get('op'), error), xbmc.LOGWARNING)
            break
    response_path = os.path.join(base, 'response-{}.json'.format(request_id))
    tmp_path = response_path + '.tmp'
    with open(tmp_path, 'w', encoding='utf-8') as handle:
        json.dump({'id': request_id, 'ok': error is None, 'error': error, 'results': results},
                  handle, ensure_ascii=False, indent=2)
    os.replace(tmp_path, response_path)
    os.remove(processing)


def run_listener():
    """Block until Kodi exits; serve harness requests while ENABLED exists."""
    monitor = xbmc.Monitor()
    base = _dir()
    request_path = os.path.join(base, 'request.json')
    enabled_path = os.path.join(base, 'ENABLED')
    heartbeat_path = os.path.join(base, 'heartbeat')
    _log('listener ready ({})'.format(base))
    while not monitor.abortRequested():
        if not os.path.exists(enabled_path):
            if monitor.waitForAbort(IDLE_POLL_S):
                break
            continue
        try:
            with open(heartbeat_path, 'w') as handle:
                handle.write(str(time.time()))
            if os.path.exists(request_path):
                _process(request_path, base)
        except Exception as exc:
            _log('listener error: {}'.format(exc), xbmc.LOGERROR)
        if monitor.waitForAbort(ACTIVE_POLL_S):
            break
