# -*- coding: utf-8 -*-
"""Drivers of one Millionaire game: headless (real addon code in this process) or live (the game in running Kodi).

Both offer the same interface, so scenarios, the REPL and the CLI do not care where the game runs:
    snapshot()            state of the game (wcs/devtools/game_probe.snapshot)
    act(intent)           what a click on the control of the intent does (onClick in the game modal)
    click(intent)         live: a real Kodi click (SendClick) on the control; headless: same as act
    back()                Back from the remote
    wait(pred, timeout)   block until pred(snapshot) is true -> snapshot
    events()              trace events so far
    chat()                system prompt + chat history of the AI content
"""
import json
import os
import shutil
import sys
import time
import uuid

import env

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_ROOT = os.path.join(HERE, 'out')
LIVE_DIR = os.path.join(env.KODI_HOME, 'userdata', 'addon_data', 'plugin.video.milionar', 'harness', 'game')


class Timeout(RuntimeError):
    pass


class _Driver:
    kind = ''

    def wait(self, pred, timeout=180.0, poll=0.05):
        deadline = time.time() + timeout
        snap = self.snapshot()
        while not pred(snap):
            if time.time() > deadline:
                raise Timeout('timeout after {:.0f}s; state: {}'.format(timeout, _brief(snap)))
            env.real_sleep(poll)
            snap = self.snapshot()
        return snap

    def wait_settled(self, screens=None, timeout=180.0, extra=None):
        screens = set(screens) if screens else None

        def ready(snap):
            if not snap.get('alive'):
                return True
            if not snap.get('settled'):
                return False
            if screens and snap.get('screen') not in screens:
                return False
            return extra(snap) if extra else True
        return self.wait(ready, timeout)


def _brief(snap):
    return {k: snap.get(k) for k in ('alive', 'screen', 'game_state', 'round', 'settled', 'focus')}


# ---------------------------------------------------------------------------
# prompt overrides (headless only)
# ---------------------------------------------------------------------------

def _text(value):
    """'@path' -> file content, else the text itself."""
    if value and value.startswith('@'):
        with open(value[1:], encoding='utf-8') as handle:
            return handle.read()
    return value


def apply_prompt_overrides(system_file=None, system_append=None, system_replace=(), msg_append=()):
    """Patch wcs.games.millionaire_prompts for this process; returns a description for the report.

    system_file: the whole system prompt ({{BASE}} in it stands for the production prompt)
    system_append: text added to the end of the system prompt
    system_replace: ["old=>new", ...] substitutions in the system prompt (each must match)
    msg_append: ["welcome|question|feedback=text", ...] text added to that user message
    """
    from wcs.games import millionaire_prompts as prompts
    applied = {}
    if system_file or system_append or system_replace:
        base_build = prompts.build_system_prompt
        file_text = _text('@' + system_file) if system_file else None
        append = _text(system_append) if system_append else ''
        pairs = []
        for item in system_replace or ():
            old, sep, new = item.partition('=>')
            if not sep:
                raise SystemExit('--system-replace needs OLD=>NEW: ' + item)
            pairs.append((_text(old), _text(new)))

        def build_system_prompt(*args, **kwargs):
            base = base_build(*args, **kwargs)
            text = file_text.replace('{{BASE}}', base) if file_text is not None else base
            for old, new in pairs:
                if old not in text:
                    raise ValueError('--system-replace: text not found in the system prompt: {!r}'.format(old[:80]))
                text = text.replace(old, new)
            if append:
                text = text.rstrip() + '\n\n' + append.strip()
            return text
        prompts.build_system_prompt = build_system_prompt
        applied.update({'system_file': system_file, 'system_append': append or None,
                        'system_replace': [list(p) for p in pairs] or None})
    builders = {'welcome': 'build_welcome_message', 'question': 'build_question_message',
                'feedback': 'build_feedback_message'}
    for item in msg_append or ():
        kind, sep, value = item.partition('=')
        if not sep or kind not in builders:
            raise SystemExit('--msg-append needs welcome|question|feedback=TEXT: ' + item)
        original = getattr(prompts, builders[kind])
        extra = _text(value)

        def make(original, extra):
            return lambda *a, **k: original(*a, **k).rstrip() + ' ' + extra.strip()
        setattr(prompts, builders[kind], make(original, extra))
        applied.setdefault('msg_append', {})[kind] = extra
    return applied


# ---------------------------------------------------------------------------
# headless
# ---------------------------------------------------------------------------

def subject_from_args(demo_meta=None, title=None, plot='', genre='', media='movie', year=''):
    """What the game is about: a demo title's metadata (offline, known good) or explicit fields."""
    if demo_meta:
        from wcs.games.demo import catalog
        item = catalog.get(demo_meta)
        if item is None:
            raise SystemExit('unknown demo title: {} (known: {})'.format(
                demo_meta, ', '.join(t.id for t in catalog.titles())))
        return {'id': item.id, 'title': item.title, 'plot': item.plot, 'genre': item.genre, 'media': item.media,
                'year': str(item.year)}
    if not title:
        raise SystemExit('give --demo ID, --meta ID or --title')
    return {'id': _slug(title), 'title': title, 'plot': plot or '', 'genre': genre or '', 'media': media or 'movie',
            'year': str(year or '')}


def _slug(text):
    return ''.join(c if c.isalnum() else '-' for c in text.lower()).strip('-')[:40] or 'game'


class HeadlessGame(_Driver):
    """The real game modal, adapter, controller and content source, without Kodi."""
    kind = 'headless'

    def __init__(self, out_dir, subject, source='ai', design=None, pace=0.0, mode='millionaire'):
        import xbmc
        import stubs
        from wcs.devtools import game_probe
        from wcs.games import designs
        from wcs.games.DialogMilionarGame import MilionarGameModal

        self.out_dir = out_dir
        os.makedirs(out_dir, exist_ok=True)
        self.trace = game_probe.Trace(os.path.join(out_dir, 'trace.jsonl'))
        self._log_path = os.path.join(out_dir, 'kodi.log')

        def log(msg, level=1):
            stubs.LOG.append((level, msg))
            with open(self._log_path, 'a', encoding='utf-8') as handle:
                handle.write('{} {}\n'.format(('DEBUG', 'INFO', 'WARNING', 'ERROR', 'FATAL')[min(level, 4)], msg))
            if level >= 2:
                self.trace.emit('kodi.log', level=level, message=str(msg)[:2000])
        xbmc.log = log

        content = None                          # None: the controller builds the live AI content (as in Kodi)
        if source == 'demo':
            from wcs.games.content.demo import DemoContent
            from wcs.games.demo import catalog
            content = DemoContent(catalog.get(subject['id']), pace=pace)
        chosen = designs.DESIGNS.get(design, None) if design else None
        self.modal = MilionarGameModal(title=subject['title'], plot=subject['plot'], genre=subject['genre'],
                                       media_type=subject['media'], content=content, design=chosen,
                                       year=subject.get('year', ''), mode=mode,
                                       demo_id=subject.get('id', '') if source == 'demo' else '')
        self.modal.onInit()
        self.bridge = game_probe.Bridge(self.modal, out_dir, self.trace).start()
        self._probe = game_probe

    def snapshot(self):
        return self._probe.snapshot(self.modal)

    def act(self, intent):
        return self._command({'op': 'act', 'intent': intent})

    click = act

    def back(self):
        return self._command({'op': 'back'})

    def command(self, op, **kwargs):
        kwargs['op'] = op
        return self._command(kwargs)

    def _command(self, command):
        result = self.bridge.execute(command)
        if not result['ok']:
            raise RuntimeError(result['error'])
        return result['result']

    def chat(self):
        return self._command({'op': 'chat'})

    def events(self):
        return list(self.trace.events)

    def close(self):
        try:
            if self.modal._adapter:
                self.modal.close()
        finally:
            self.bridge.stop()


# ---------------------------------------------------------------------------
# channel to a running bridge (live Kodi, or a headless `serve` daemon)
# ---------------------------------------------------------------------------

class Channel(_Driver):
    """Talks to a Bridge through its folder (state.json, cmd/, res-*.json, trace.jsonl)."""

    def __init__(self, folder, kind):
        self.folder = folder
        self.kind = kind

    def snapshot(self):
        path = os.path.join(self.folder, 'state.json')
        for _ in range(20):
            try:
                with open(path, encoding='utf-8') as handle:
                    return json.load(handle)
            except FileNotFoundError:
                return {'alive': False, 'missing': True}
            except ValueError:
                env.real_sleep(0.05)
        return {'alive': False, 'unreadable': True}

    def command(self, op, timeout=30.0, **kwargs):
        command_id = uuid.uuid4().hex[:10]
        kwargs.update({'op': op, 'id': command_id})
        cmd_dir = os.path.join(self.folder, 'cmd')
        if not os.path.isdir(cmd_dir):
            raise SystemExit('no game probe at {} (is the game open?)'.format(self.folder))
        path = os.path.join(cmd_dir, '{:.6f}-{}.json'.format(time.time(), command_id))
        with open(path + '.tmp', 'w', encoding='utf-8') as handle:
            json.dump(kwargs, handle, ensure_ascii=False)
        os.replace(path + '.tmp', path)
        result_path = os.path.join(self.folder, 'res-{}.json'.format(command_id))
        deadline = time.time() + timeout
        while time.time() < deadline:
            if os.path.exists(result_path):
                with open(result_path, encoding='utf-8') as handle:
                    result = json.load(handle)
                os.remove(result_path)
                if not result.get('ok'):
                    raise RuntimeError('{}\n{}'.format(result.get('error'), result.get('traceback', '')))
                return result.get('result')
            env.real_sleep(0.05)
        if os.path.exists(path):
            os.remove(path)
        raise Timeout('no answer from the game probe in {:.0f}s ({})'.format(timeout, self.folder))

    def act(self, intent):
        return self.command('act', intent=intent)

    def click(self, intent):
        return self.act(intent)

    def back(self):
        return self.command('back')

    def chat(self):
        return self.command('chat')

    def events(self):
        path = os.path.join(self.folder, 'trace.jsonl')
        if not os.path.exists(path):
            return []
        events = []
        with open(path, encoding='utf-8') as handle:
            for line in handle:
                try:
                    events.append(json.loads(line))
                except ValueError:
                    pass
        return events

    def close(self):
        pass


class LiveGame(Channel):
    """The game modal in the running Kodi (probe attached because harness/ENABLED exists)."""

    def __init__(self):
        Channel.__init__(self, LIVE_DIR, 'live')
        sys.path.insert(0, os.path.join(env.ADDON_DIR, 'scripts', 'dialog_harness'))
        import kodi_live
        self.kodi = kodi_live

    def kodi_ops(self, ops, timeout=60.0):
        return self.kodi.request(ops, timeout=timeout)

    def open(self, url, timeout=30.0):
        """Close a running game, run a plugin URL that opens the game and wait for its (new) probe."""
        self.close()
        before = self.snapshot().get('probe')
        self.kodi_ops([{'op': 'builtin', 'cmd': 'RunPlugin({})'.format(url)}])
        return self.wait(lambda s: s.get('alive') and s.get('probe') != before, timeout)

    def click(self, intent):
        """A real Kodi click on the intent's control (focus/visibility rules of Kodi apply)."""
        from wcs.devtools import game_probe
        from wcs.games import designs
        snap = self.snapshot()
        design = {d.key: d for d in designs.DESIGNS.values()}.get(snap.get('design'), designs.DEFAULT)
        control = game_probe.control_for(design, intent, snap.get('mode', ''), snap.get('screen'))
        if control is None:
            raise ValueError('intent {!r} has no control in design {}'.format(intent, design.key))
        self.kodi_ops([{'op': 'builtin', 'cmd': 'SendClick({})'.format(control)}])
        return {'control': control}

    def back(self):
        self.kodi_ops([{'op': 'builtin', 'cmd': 'Action(Back)'}])
        return {}

    def screenshot(self, path):
        return self.kodi_ops([{'op': 'screenshot', 'path': os.path.abspath(path)}])

    def close(self, timeout=10.0):
        if self.snapshot().get('alive'):
            try:
                self.command('close', timeout=timeout)
                self.wait(lambda s: not s.get('alive'), timeout)
            except Timeout:                       # stale state.json of a game that is gone (Kodi restarted)
                pass


def live_url(demo=None, subject=None, mode=None):
    from urllib.parse import urlencode
    base = 'plugin://plugin.video.milionar/'
    if demo:
        params = {'action': 'milionar_demo', 'demo': demo}
        if mode:
            params['mode'] = mode
        return base + '?' + urlencode(params)
    params = {'action': 'milionar_game', 'title': subject['title'], 'media_type': subject['media'],
              'plot': subject.get('plot', '')}
    if subject.get('tmdb_id'):
        params['tmdb_id'] = subject['tmdb_id']
    if mode:
        params['mode'] = mode
    if subject.get('year'):
        params['year'] = subject['year']
    return base + '?' + urlencode(params)


def new_run_dir(label):
    stamp = time.strftime('%Y%m%d-%H%M%S')
    base = path = os.path.join(OUT_ROOT, 'runs', '{}-{}'.format(stamp, _slug(label)))
    suffix = 1
    while True:
        try:
            os.makedirs(path)                     # atomic: two runs started in the same second get different folders
            break
        except FileExistsError:
            suffix += 1
            path = '{}-{}'.format(base, suffix)
    return path


def copy_live_trace(dest):
    source = os.path.join(LIVE_DIR, 'trace.jsonl')
    if os.path.exists(source):
        shutil.copyfile(source, os.path.join(dest, 'trace.jsonl'))
