#!/usr/bin/env python3
"""Skin baseline: capture the screen set under a skin, compare two captures.

  baseline.py capture [--skin estuary|milionar] --out DIR [--only SUBSTR ...] [--list]
  baseline.py compare DIR_A DIR_B [--report DIR]
  baseline.py difftree            resources/skin.milionar vs the Estuary 21.3-Omega tag (030 §4)

Every screen in screens.json is reached from a clean Home, captured natively
(2560x1440, no scaling) and saved as <name>.png + <name>.json (window, focus,
labels, timings). `compare` diffs two directories pixel by pixel outside the
masks declared in screens.json and writes report.md plus side_by_side.png and
diff.png for every screen that is not `same`.

Coordinates in screens.json (masks, expect) are skin pixels (1920x1080); the
tool multiplies them by frame width / 1920.

Step vocabulary (screens.json "steps"):
  {"builtin": "Action(Info)"}      Kodi builtin
  {"keys": ["Down", "Select"]}     actions, 250 ms apart
  {"window": "Videos", "path": P}  ActivateWindow(<name>[,P,return]); templates {movies} ...
  {"focus": 9000, "pos": 2}        SetFocus(id[,pos,absolute])
  {"view": 51}                     Container.SetViewMode
  {"std": "yesno"}                 standard dialog from the listener (wait cond optional)
  {"play": true}                   play the local fixture video, paused at 0
  {"plugin": "action=my_tv_dialog"} RunPlugin on the addon
  {"sleep": 500} / {"wait": "Window.IsActive(x)"}
"""
import argparse
import json
import os
import subprocess
import sys
import time

import numpy as np
from PIL import Image

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
sys.path.insert(0, os.path.join(ROOT, 'scripts', 'dialog_harness'))

import kodi_live as kl  # noqa: E402
from compare_images import heatmap, side_by_side  # noqa: E402

SCREENS = os.path.join(HERE, 'screens.json')
FIXTURE = os.path.join(HERE, 'fixtures', 'baseline_sample.mp4')
LABELS = ['System.CurrentWindow', 'System.CurrentControl', 'System.CurrentControlID',
          'Container.NumItems', 'Container.Viewmode', 'System.ScreenResolution']
RESET = [
    {'op': 'close_kept'},
    {'op': 'stddialog', 'kind': 'close'},
    {'op': 'builtin', 'cmd': 'PlayerControl(Stop)'},
    {'op': 'builtin', 'cmd': 'Dialog.Close(all,true)'},
    {'op': 'builtin', 'cmd': 'ActivateWindow(Home)'},
    {'op': 'wait', 'cond': 'Window.IsActive(home) + !Window.IsActive(busydialog)', 'timeout_ms': 10000},
    {'op': 'builtin', 'cmd': 'SetFocus(9000,0,absolute)'},
    {'op': 'sleep', 'ms': 1200},
]


# --------------------------------------------------------------------------- screens

def load_screens():
    with open(SCREENS, encoding='utf-8') as handle:
        data = json.load(handle)
    defaults = data.get('defaults', {})
    screens = []
    for entry in data['screens']:
        views = entry.get('views')
        if views:
            for view in views:
                copy = json.loads(json.dumps(entry))
                copy.pop('views')
                copy['name'] = '{}_view{}'.format(entry['name'], view)
                copy['steps'] = list(entry['steps']) + [{'view': view}]
                copy['masks'] = list(entry.get('masks', [])) + entry.get('masks_by_view', {}).get(str(view), [])
                screens.append(copy)
        else:
            screens.append(entry)
    for screen in screens:
        if not any('window' in step for step in screen['steps']) and not screen['name'].startswith('playback'):
            # Home stays visible behind the screen: the random-movies row lives there (050 §5 / 100 §5)
            screen['masks'] = screen.get('masks', []) + [[462, 850, 1920, 1080]]
            # the fork changes Home itself (050), so whatever is drawn over it may differ as a whole
            screen.setdefault('expect', [[0, 0, 1920, 1080]])
        for key, value in defaults.items():
            if key == 'masks':
                screen['masks'] = list(value) + screen.get('masks', [])
            else:
                screen.setdefault(key, value)
    return screens


def templates():
    """Library-dependent paths, resolved once from JSON-RPC."""
    def rpc(method, params):
        res = kl.request([{'op': 'jsonrpc', 'request': {
            'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}}])
        return res['results'][0]['result'].get('result', {})
    shows = rpc('VideoLibrary.GetTVShows', {'sort': {'method': 'title'}}).get('tvshows', [])
    values = {'movies': 'videodb://movies/titles/', 'tvshows': 'videodb://tvshows/titles/',
              'addon': 'plugin://plugin.video.milionar/', 'sources': 'sources://video/'}
    if shows:
        base = 'videodb://tvshows/titles/{}/'.format(shows[0]['tvshowid'])
        values.update({'tvshow': base, 'season1': base + '1/', 'tvshow_title': shows[0]['label']})
    return values


def ensure_fixture():
    if os.path.exists(FIXTURE):
        return FIXTURE
    os.makedirs(os.path.dirname(FIXTURE), exist_ok=True)
    # static picture (deterministic frames) with a tone; 120 s so the seek bar has a range
    subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i',
                    'smptehdbars=size=1280x720:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
                    '-t', '120', '-c:v', 'libx264', '-g', '1', '-pix_fmt', 'yuv420p', '-c:a', 'aac', FIXTURE],
                   check=True)
    return FIXTURE


def build_ops(screen, tpl):
    ops = list(RESET)
    for step in screen['steps']:
        if 'builtin' in step:
            ops.append({'op': 'builtin', 'cmd': step['builtin'].format(**tpl)})
        elif 'keys' in step:
            for key in step['keys']:
                cmd = key if '(' in key else 'Action({})'.format(key)
                ops += [{'op': 'builtin', 'cmd': cmd}, {'op': 'sleep', 'ms': step.get('step', 250)}]
        elif 'window' in step:
            path = step.get('path', '').format(**tpl)
            cmd = 'ActivateWindow({},{},return)'.format(step['window'], path) if path \
                else 'ActivateWindow({})'.format(step['window'])
            ops += [{'op': 'builtin', 'cmd': cmd},
                    {'op': 'wait', 'cond': 'Window.IsActive({}) + !Window.IsActive(busydialog)'.format(
                        step['window']), 'timeout_ms': 15000},
                    {'op': 'sleep', 'ms': step.get('after', 1200)}]
            if step['window'] == 'Videos' and path:
                # Kodi remembers view and position per path: pin both so a run never depends on the last one
                view = step.get('view', 50)
                ops += [{'op': 'builtin', 'cmd': 'Container.SetViewMode({})'.format(view)},
                        {'op': 'sleep', 'ms': 900},
                        {'op': 'builtin', 'cmd': 'SetFocus({},{},absolute)'.format(view, step.get('pos', 1))},
                        {'op': 'sleep', 'ms': 900}]
        elif 'focus' in step:
            arg = str(step['focus']) if 'pos' not in step else '{},{},absolute'.format(step['focus'], step['pos'])
            ops += [{'op': 'builtin', 'cmd': 'SetFocus({})'.format(arg)}, {'op': 'sleep', 'ms': step.get('after', 600)}]
        elif 'view' in step:
            ops += [{'op': 'builtin', 'cmd': 'Container.SetViewMode({})'.format(step['view'])},
                    {'op': 'sleep', 'ms': 1200},
                    {'op': 'builtin', 'cmd': 'SetFocus({},{},absolute)'.format(step['view'], step.get('pos', 1))},
                    {'op': 'sleep', 'ms': 900}]
        elif 'dialog' in step:
            props, items = kl.fixture(step.get('profile', 'movie'), [])
            ops.append({'op': 'dialog', 'xml': step['dialog'], 'props': props,
                        'items': {str(k): v for k, v in items.items()}, 'focus': step.get('focus'),
                        'settle_ms': 1500, 'close': False, 'backdrop': None})
        elif 'std' in step:
            ops.append({'op': 'stddialog', 'kind': step['std'], 'wait_cond': step.get('wait')})
        elif 'play' in step:
            # every frame a keyframe (-g 1) so the paused position is exact; 25 % = 30 s
            ops += [{'op': 'builtin', 'cmd': 'PlayMedia({})'.format(ensure_fixture())},
                    {'op': 'wait', 'cond': 'Player.HasVideo + Player.Playing', 'timeout_ms': 15000},
                    {'op': 'sleep', 'ms': 700}, {'op': 'builtin', 'cmd': 'PlayerControl(Pause)'},
                    {'op': 'wait', 'cond': 'Player.Paused', 'timeout_ms': 5000},
                    {'op': 'builtin', 'cmd': 'SeekPercentage(25)'}, {'op': 'sleep', 'ms': 1500}]
        elif 'plugin' in step:
            ops.append({'op': 'builtin', 'cmd': 'RunPlugin(plugin://plugin.video.milionar/?{})'.format(step['plugin'])})
            ops.append({'op': 'sleep', 'ms': step.get('after', 2500)})
        elif 'sleep' in step:
            ops.append({'op': 'sleep', 'ms': step['sleep']})
        elif 'wait' in step:
            ops.append({'op': 'wait', 'cond': step['wait'], 'timeout_ms': step.get('timeout', 10000)})
        else:
            raise SystemExit('unknown step {} in {}'.format(step, screen['name']))
    return ops


# --------------------------------------------------------------------------- capture

ESTUARY_SETTINGS = os.path.expanduser(
    '~/Library/Application Support/Kodi/userdata/addon_data/skin.estuary/settings.xml')


def sync_skin_settings():
    """Give a forked skin the same skin settings as Estuary (hidden Home items, ...): same conditions."""
    import xml.etree.ElementTree as ET
    if not os.path.exists(ESTUARY_SETTINGS):
        return 0
    ops = []
    for node in ET.parse(ESTUARY_SETTINGS).getroot():
        key, kind, value = node.get('id'), node.get('type'), (node.text or '')
        if kind == 'bool':
            cmd = 'Skin.SetBool({})'.format(key) if value == 'true' else 'Skin.Reset({})'.format(key)
        elif value:
            cmd = 'Skin.SetString({},{})'.format(key, value)
        else:
            continue
        ops.append({'op': 'builtin', 'cmd': cmd})
    kl.request(ops)
    return len(ops)


def cmd_capture(args):
    screens = load_screens()
    if args.only:
        screens = [s for s in screens if any(k in s['name'] for k in args.only)]
    if args.list:
        for screen in screens:
            print(screen['name'])
        return
    out_dir = os.path.abspath(args.out)
    os.makedirs(out_dir, exist_ok=True)
    if args.skin:
        name = args.skin if args.skin.startswith('skin.') else 'skin.' + args.skin
        kl.request([{'op': 'skin', 'name': name}], timeout=90)
    active = kl.ping()
    if active['skin'] != 'skin.estuary':
        print('synced {} skin setting(s) from Estuary'.format(sync_skin_settings()))
    print('skin {}  gui {}x{}'.format(active['skin'], active['screen_w'], active['screen_h']))
    tpl = templates()
    failures = []
    for screen in screens:
        name = screen['name']
        path = os.path.join(out_dir, name + '.png')
        ops = build_ops(screen, tpl)
        ops += [{'op': 'sleep', 'ms': screen.get('settle', 1500)},
                {'op': 'info', 'labels': LABELS + screen.get('labels', [])},
                {'op': 'screenshot', 'path': path}]
        started = time.time()
        try:
            data = kl.request(ops, timeout=120)
        except SystemExit as exc:
            failures.append(name)
            print('FAIL  {}: {}'.format(name, str(exc).splitlines()[0]))
            continue
        info = data['results'][-2]['result']['labels']
        info.update({'skin': active['skin'], 'seconds': round(time.time() - started, 1),
                     'size': list(Image.open(path).size)})
        with open(os.path.join(out_dir, name + '.json'), 'w', encoding='utf-8') as handle:
            json.dump(info, handle, ensure_ascii=False, indent=1)
        if screen.get('cooldown'):
            kl.request([{'op': 'sleep', 'ms': screen['cooldown']}])
        print('ok    {:<38} {:<20} {}s'.format(name, info['System.CurrentWindow'], info['seconds']))
    kl.request(RESET)
    if failures:
        raise SystemExit('{} screen(s) failed: {}'.format(len(failures), ', '.join(failures)))


# --------------------------------------------------------------------------- compare

def scaled(box, factor):
    return tuple(int(round(v * factor)) for v in box)


def apply_masks(arr, boxes, factor):
    arr = arr.copy()
    for box in boxes:
        x0, y0, x1, y1 = scaled(box, factor)
        arr[y0:y1, x0:x1] = 0
    return arr


def cmd_compare(args):
    screens = {s['name']: s for s in load_screens()}
    dir_a, dir_b = os.path.abspath(args.a), os.path.abspath(args.b)
    report_dir = os.path.abspath(args.report or os.path.join(HERE, 'out', 'report'))
    os.makedirs(report_dir, exist_ok=True)
    names = sorted(n[:-4] for n in os.listdir(dir_a) if n.endswith('.png'))
    if args.only:
        names = [n for n in names if any(key in n for key in args.only)]
    rows, worst = [], 0
    for name in names:
        path_a, path_b = os.path.join(dir_a, name + '.png'), os.path.join(dir_b, name + '.png')
        if not os.path.exists(path_b):
            rows.append((name, 'missing in B', '', '', 'REGRESSION'))
            worst += 1
            continue
        img_a, img_b = Image.open(path_a).convert('RGB'), Image.open(path_b).convert('RGB')
        if img_a.size != img_b.size:
            rows.append((name, 'size {} vs {}'.format(img_a.size, img_b.size), '', '', 'REGRESSION'))
            worst += 1
            continue
        factor = img_a.size[0] / 1920.0
        screen = screens.get(name, {})
        a, b = np.asarray(img_a), np.asarray(img_b)
        masks = screen.get('masks', [])
        am, bm = apply_masks(a, masks, factor), apply_masks(b, masks, factor)
        # tolerance > 0 only for screens with a pulsing focus glow (off by 1 level between frames)
        differ = (np.abs(am.astype(np.int16) - bm.astype(np.int16)).max(axis=2) > screen.get('tolerance', 0))
        count = int(differ.sum())
        total = differ.size
        if count == 0:
            rows.append((name, '100 %', 0, '', 'same'))
            continue
        ys, xs = np.where(differ)
        bbox = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
        expect = screen.get('expect', [])
        # every differing pixel must lie inside one of the declared regions
        allowed = np.zeros(differ.shape, dtype=bool)
        for box in expect:
            x0, y0, x1, y1 = scaled(box, factor)
            allowed[y0:y1, x0:x1] = True
        status = 'expected change' if expect and not (differ & ~allowed).any() else 'REGRESSION'
        if status == 'REGRESSION':
            worst += 1
        bbox_skin = tuple(int(round(v / factor)) for v in bbox)
        rows.append((name, '{:.4f} %'.format(100.0 * (1 - count / total)), count, str(bbox_skin), status))
        diff = np.abs(am.astype(np.int16) - bm.astype(np.int16)).astype(np.uint8)
        heat = heatmap(diff, differ)
        small = lambda im: im.resize((im.width // 2, im.height // 2), Image.LANCZOS)  # noqa: E731
        side_by_side(small(Image.fromarray(am)), small(Image.fromarray(bm)), small(heat),
                     ('A: ' + dir_a[-30:], 'B: ' + dir_b[-30:], 'diff x4')).save(
            os.path.join(report_dir, name + '_side_by_side.png'))
        heat.save(os.path.join(report_dir, name + '_diff.png'))
    lines = ['# Skin baseline compare', '', 'A = `{}`'.format(dir_a), 'B = `{}`'.format(dir_b), '',
             '| screen | identical | differing px | bbox (skin px) | status |', '|---|---|---|---|---|']
    lines += ['| {} | {} | {} | {} | {} |'.format(*row) for row in rows]
    counts = {}
    for row in rows:
        counts[row[-1]] = counts.get(row[-1], 0) + 1
    lines += ['', 'Summary: ' + ', '.join('{} {}'.format(v, k) for k, v in sorted(counts.items()))]
    with open(os.path.join(report_dir, 'report.md'), 'w', encoding='utf-8') as handle:
        handle.write('\n'.join(lines) + '\n')
    print('\n'.join(lines))
    print('\nreport: ' + os.path.join(report_dir, 'report.md'))
    if worst:
        raise SystemExit(1)


# --------------------------------------------------------------------------- difftree

ESTUARY_REPO = 'https://github.com/xbmc/xbmc.git'
ESTUARY_TAG = '21.3-Omega'
ESTUARY_COMMIT = 'a3a448d26b'
ESTUARY_CACHE = os.path.join(HERE, 'out', 'estuary-' + ESTUARY_TAG)
SKIN_TREE = os.path.join(ROOT, 'resources', 'skin.milionar')
# 030 §4: the only files that may differ from the tag (glob patterns, relative to the skin root)
ALLOWED = ['addon.xml', 'changelog.txt', 'resources/icon.png', 'resources/fanart.jpg', 'resources/fanart.png',
           'resources/screenshot-*.jpg', 'resources/screenshot-*.png', 'media/milionar/*',
           # 180: approved accent-only themes; default backgrounds stay unchanged.
           'colors/defaults.xml', 'colors/milionar-*.xml',
           'xml/Font.xml', 'fonts/NotoSans-MilionarBrand.ttf',
           'xml/Home.xml', 'xml/SkinSettings.xml', 'xml/DialogVideoInfo.xml',
           'xml/Custom_1108_MilionarSettingsMenu.xml', 'xml/Custom_1120_MilionarSearch.xml',
           'xml/Custom_1122_MilionarGame.xml',
           # milionar-library (L3, L4): slot editor, generated slot includes, library look of the lists
           'xml/Custom_1123_MilionarRows.xml', 'xml/Includes_MilionarSlots.xml', 'xml/Includes.xml',
           # 190-cinema: generated hero and rows of the cinematic Home sections (textures: media/milionar/cinema)
           'xml/Includes_MilionarCinema.xml', 'xml/Includes_MilionarDetail.xml',
           # 230-sidebar: collapsing Home sidebar
           'xml/Includes_MilionarSidebar.xml',
           'xml/Includes_Home.xml', 'xml/Variables.xml', 'xml/DialogContextMenu.xml',
           'language/resource.language.cs_cz/strings.po', 'language/resource.language.sk_sk/strings.po',
           'language/resource.language.en_gb/strings.po']


def estuary_reference():
    source = os.path.join(ESTUARY_CACHE, 'addons', 'skin.estuary')
    if not os.path.isdir(source):
        os.makedirs(os.path.dirname(ESTUARY_CACHE), exist_ok=True)
        subprocess.run(['git', 'clone', '--depth', '1', '--branch', ESTUARY_TAG, '--filter=blob:none',
                        '--sparse', ESTUARY_REPO, ESTUARY_CACHE], check=True, capture_output=True)
        subprocess.run(['git', '-C', ESTUARY_CACHE, 'sparse-checkout', 'set', 'addons/skin.estuary'],
                       check=True, capture_output=True)
    head = subprocess.run(['git', '-C', ESTUARY_CACHE, 'rev-parse', 'HEAD'], capture_output=True,
                          text=True).stdout.strip()
    if not head.startswith(ESTUARY_COMMIT):
        raise SystemExit('reference checkout is {} not {}'.format(head, ESTUARY_COMMIT))
    return source


def tree_files(root):
    found = {}
    for base, _dirs, files in os.walk(root):
        for name in files:
            if name == '.DS_Store':
                continue
            path = os.path.join(base, name)
            found[os.path.relpath(path, root)] = path
    return found


def cmd_difftree(args):
    import fnmatch
    ref = tree_files(estuary_reference())
    fork = tree_files(SKIN_TREE)
    changed = []
    for rel in sorted(set(ref) | set(fork)):
        if rel not in fork:
            changed.append((rel, 'removed'))
        elif rel not in ref:
            changed.append((rel, 'added'))
        elif open(ref[rel], 'rb').read() != open(fork[rel], 'rb').read():
            changed.append((rel, 'changed'))
    bad = [(rel, kind) for rel, kind in changed if not any(fnmatch.fnmatch(rel, pat) for pat in ALLOWED)]
    for rel, kind in changed:
        print('{:<8} {}{}'.format(kind, rel, '' if (rel, kind) not in bad else '   <-- NOT ALLOWED (030 §4)'))
    print('{} file(s) differ from {} ({}), {} not allowed'.format(
        len(changed), ESTUARY_TAG, ESTUARY_COMMIT, len(bad)))
    if bad:
        raise SystemExit(1)


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = parser.add_subparsers(dest='cmd', required=True)
    p = sub.add_parser('capture')
    p.add_argument('--skin')
    p.add_argument('--out', default=os.path.join(HERE, 'out', 'capture'))
    p.add_argument('--only', nargs='*')
    p.add_argument('--list', action='store_true')
    p = sub.add_parser('compare')
    p.add_argument('a')
    p.add_argument('b')
    p.add_argument('--report')
    p.add_argument('--only', nargs='*')
    sub.add_parser('difftree')
    args = parser.parse_args()
    {'capture': cmd_capture, 'compare': cmd_compare, 'difftree': cmd_difftree}[args.cmd](args)


if __name__ == '__main__':
    main()
