#!/usr/bin/env python3
"""Offline release texture contracts; writes only to a temporary skin stage.

Run: python3 scripts/skin_harness/test_release_textures.py /tmp/plugin.video.milionar-2.1.16.zip
Optional second argument: system Estuary media/Textures.xbt. No Kodi/network calls.
"""
import copy
import hashlib
from pathlib import Path
import re
import shutil
import struct
import sys
import tempfile
import types
import unittest

# Obsolete: these checks belong to the abandoned "lean" packaging that rewrote texture paths to the native Estuary
# bundle. The release now ships the tree unchanged (scripts/build_zip.sh), so there is nothing left to assert here.
raise unittest.SkipTest('lean native-texture packaging was replaced by shipping the tree unchanged')
from unittest.mock import Mock, patch
import xml.etree.ElementTree as ET
from zipfile import ZipFile

ZIP_PATH = Path('/tmp/plugin.video.milionar-2.1.16.zip')
XBT_PATH = Path('/Applications/Kodi.app/Contents/Resources/Kodi/addons/skin.estuary/media/Textures.xbt')
ROOT = 'plugin.video.milionar/'
SKIN = 'resources/skin.milionar/'
# Kodi 21.3 GUIControlFactory::GetTexture consumers, including legacy radio tags.
STATIC_TAGS = set('''texturefocus texturenofocus alttexturefocus alttexturenofocus
textureup texturedown textureupfocus texturedownfocus textureupdisabled texturedowndisabled
textureradioonfocus textureradioonnofocus textureradioofffocus textureradiooffnofocus
textureradioondisabled textureradiooffdisabled textureradiofocus textureradionofocus
textureradioon textureradiooff texturesliderbackground texturesliderbar texturesliderbarfocus
texturesliderbardisabled textureslidernib textureslidernibfocus textureslidernibdisabled
texturecolormask texturecolordisabledmask texturebg lefttexture midtexture righttexture
overlaytexture bordertexture progresstexture'''.split())
PARAM = re.compile(r'\$PARAM\[([^\]]+)\]')
RASTER = re.compile(r'^[A-Za-z0-9_./-]+\.(?:png|jpg|gif)$', re.I)


def bundle_names(path):
    names = set()
    with path.open('rb') as archive:
        if archive.read(5) != b'XBTF2':
            raise ValueError('Expected system Kodi 21 XBTF2 archive')
        count, = struct.unpack('<I', archive.read(4))
        for _ in range(count):
            names.add(archive.read(256).split(b'\0', 1)[0].decode('utf-8'))
            _loop, frames = struct.unpack('<II', archive.read(8))
            archive.seek(frames * 40, 1)
    return names


class ReleaseTextureTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.archive = ZipFile(ZIP_PATH)
        cls.addClassCleanup(cls.archive.close)
        cls.native = bundle_names(XBT_PATH)
        cls.xml = {name[len(ROOT):]: ET.fromstring(cls.archive.read(name))
                   for name in cls.archive.namelist() if name.endswith('.xml')}
        cls.includes, cls.defaults = {}, {}
        for name, root in cls.xml.items():
            if not name.startswith(SKIN):
                continue
            for node in root.findall('include'):
                if node.get('name'):
                    cls.includes[node.get('name')] = node
            for node in root.findall('default'):
                cls.defaults[node.get('type')] = node

    def native_path(self, value, where):
        self.assertNotIn('milionar.assets.xbt', value, where)
        if RASTER.fullmatch(value):
            self.assertIn(value, self.native, where + ': ' + value)

    def resolve(self, name, supplied=None, stack=()):
        """Resolve XML include parameters/defaults, retaining all visibility branches.

        This is structural expansion, not a Kodi control renderer or GUI test.
        """
        self.assertNotIn(name, stack, 'Recursive include: ' + name)
        definition = self.includes[name]
        params = {node.get('name'): node.get('default', node.text or '')
                  for node in definition.findall('param')}
        params.update(supplied or {})
        source = definition.find('definition')
        if source is None:
            source = definition
        result = ET.Element('resolved')
        for child in source:
            if child.tag != 'param':
                result.append(copy.deepcopy(child))
        for node in result.iter():
            if node.text:
                node.text = PARAM.sub(lambda match: params.get(match[1], ''), node.text)
            for key, value in list(node.attrib.items()):
                node.set(key, PARAM.sub(lambda match: params.get(match[1], ''), value))
        for parent in list(result.iter()):
            for node in list(parent):
                if node.tag != 'include':
                    continue
                target = node.get('content') or (node.text or '').strip()
                self.assertIn(target, self.includes, 'Unresolved include: ' + target)
                arguments = {param.get('name'): param.get('value', param.text or '')
                             for param in node.findall('param')}
                expanded = self.resolve(target, arguments, stack + (name,))
                index = list(parent).index(node)
                parent.remove(node)
                for child in expanded:
                    parent.insert(index, child)
                    index += 1
        for node in result.iter('control'):
            default = self.defaults.get(node.get('type'))
            if default is not None:
                for child in default:
                    if node.find(child.tag) is None:
                        node.append(copy.deepcopy(child))
        return result

    def test_zip_keeps_only_one_logo_and_no_texture_bundle(self):
        self.assertEqual(self.xml['addon.xml'].get('version'), '2.1.16')
        self.assertEqual(self.xml[SKIN + 'addon.xml'].get('version'), '1.3.10')
        assets = [name[len(ROOT):] for name in self.archive.namelist()
                  if Path(name).suffix.lower() in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.xbt')]
        self.assertEqual(assets, ['resources/media/brand/milionar-mark.png'])
        self.assertEqual(len(self.native), 688)

    def test_all_static_texture_tags_and_diffuse_are_literal_native_paths(self):
        checked = 0
        for name, root in self.xml.items():
            for node in root.iter():
                values = []
                if node.tag in STATIC_TAGS:
                    values.append((node.tag, (node.text or '').strip()))
                if 'diffuse' in node.attrib:
                    values.append(('diffuse', node.get('diffuse')))
                for tag, value in values:
                    with self.subTest(file=name, tag=tag, value=value):
                        self.assertNotIn('$INFO[', value)
                        self.assertNotIn('$VAR[', value)
                        self.native_path(value, name + ':' + tag)
                    checked += 1
        self.assertGreater(checked, 1000)

    def test_native_include_arguments_defaults_and_fallbacks_have_no_home_info(self):
        checked = 0
        for name, root in self.xml.items():
            for node in root.iter():
                values = []
                if node.tag == 'param':
                    values.extend([node.text or '', node.get('value', ''), node.get('default', '')])
                if node.tag == 'texture':
                    fallback = node.get('fallback', '')
                    self.assertNotIn('$INFO[', fallback, name + ':fallback')
                    self.assertNotIn('$VAR[', fallback, name + ':fallback')
                    values.append(fallback)
                for value in values:
                    with self.subTest(file=name, value=value):
                        self.native_path(value.strip(), name)
                    if RASTER.fullmatch(value.strip()):
                        checked += 1
        self.assertGreater(checked, 100)

    def test_home_settings_icon_resolves_to_radio_textures_and_keeps_focus_tint(self):
        home = self.xml[SKIN + 'xml/Home.xml']
        instance = next(node for node in home.iter('include') if node.get('content') == 'IconButton'
                        and node.find("param[@name='control_id']").get('value') == '802')
        arguments = {node.get('name'): node.get('value', node.text or '') for node in instance.findall('param')}
        self.assertEqual(arguments['icon'], 'icons/settings.png')
        control = self.resolve('IconButton', arguments).find("control[@type='radiobutton']")
        self.assertEqual(control.get('id'), '802')
        for tag in ('textureradioonfocus', 'textureradioonnofocus', 'textureradioofffocus', 'textureradiooffnofocus'):
            self.assertEqual(control.findtext(tag), 'icons/settings.png')
        self.assertEqual(control.find('textureradioonnofocus').get('colordiffuse'), 'grey')
        self.assertEqual(control.findtext('texturefocus'), 'buttons/roundbutton-fo.png')
        self.assertEqual(control.find('texturefocus').get('colordiffuse'), 'button_focus')

    def test_image_widget_buttons_keep_native_background_focus_and_borders(self):
        expanded = self.resolve('ImageWidget', {'button_id': '700', 'button_label': 'Offline', 'text_label': 'Empty'})
        buttons = expanded.findall(".//control[@type='button']")
        self.assertEqual(len(buttons), 3)
        for button in buttons:
            self.assertEqual(button.findtext('align'), 'center')
            self.assertEqual(button.findtext('texturefocus'), 'buttons/dialogbutton-fo.png')
            self.assertEqual(button.findtext('texturenofocus'), 'buttons/dialogbutton-nofo.png')
            self.assertEqual(button.find('texturefocus').get('colordiffuse'), 'button_focus')
            self.assertEqual(button.find('texturefocus').get('border'), '23')
            self.assertEqual(button.find('texturenofocus').get('border'), '40')

    def test_default_fallback_include_arguments_resolve_to_existing_native_entries(self):
        for name, expected in (('InfoWallMusicLayout', 'defaultfolder.png'),
                               ('InfoWallEpisodeLayout', 'defaulttvshows.png')):
            with self.subTest(include=name):
                textures = self.resolve(name).findall('.//texture')
                fallbacks = [node.get('fallback') for node in textures if node.get('fallback')]
                self.assertIn(expected, fallbacks)
                for path in fallbacks:
                    self.native_path(path, name)
                    self.assertNotIn('$PARAM[', path)

    def test_released_installer_copies_system_bundle_into_temporary_atomic_stage(self):
        with tempfile.TemporaryDirectory(prefix='milionar-release-textures-') as temporary:
            source, staged = Path(temporary) / 'source', Path(temporary) / 'stage'
            for name in self.archive.namelist():
                if name.startswith(ROOT + SKIN) and not name.endswith('/'):
                    destination = source / name[len(ROOT + SKIN):]
                    destination.parent.mkdir(parents=True, exist_ok=True)
                    destination.write_bytes(self.archive.read(name))
            modules = {name: types.ModuleType(name) for name in ('xbmc', 'xbmcgui', 'xbmcaddon', 'xbmcvfs')}
            modules['xbmc'].LOGINFO, modules['xbmc'].LOGWARNING, modules['xbmc'].LOGERROR = 1, 2, 3
            modules['xbmc'].log = Mock()
            modules['xbmc'].executebuiltin = Mock(side_effect=AssertionError('Unexpected Kodi command'))
            modules['xbmc'].executeJSONRPC = Mock(side_effect=AssertionError('Unexpected RPC'))
            modules['xbmcgui'].Dialog = Mock(side_effect=AssertionError('Unexpected GUI'))
            modules['xbmcvfs'].translatePath = lambda path: (
                str(XBT_PATH) if path == 'special://xbmc/addons/skin.estuary/media/Textures.xbt' else path)

            def copy_bundle(origin, destination):
                self.assertEqual(origin, 'special://xbmc/addons/skin.estuary/media/Textures.xbt')
                self.assertEqual(Path(destination), staged / 'media/Textures.xbt')
                shutil.copyfile(XBT_PATH, destination)
                return True

            modules['xbmcvfs'].copy = Mock(side_effect=copy_bundle)
            installer = types.ModuleType('released_installer')
            with patch.dict(sys.modules, modules):
                exec(compile(self.archive.read(ROOT + 'wcs/skin/installer.py'), 'released_installer.py', 'exec'), installer.__dict__)
                installer._stage_skin(str(source), str(staged), None, None)
            modules['xbmcvfs'].copy.assert_called_once()
            copied = staged / 'media/Textures.xbt'
            self.assertTrue(copied.is_file(), 'System bundle missing from atomic skin stage')
            self.assertEqual(hashlib.sha256(copied.read_bytes()).digest(), hashlib.sha256(XBT_PATH.read_bytes()).digest())
            self.assertEqual(bundle_names(copied), self.native)
            self.assertEqual((staged / 'addon.xml').read_bytes(), (source / 'addon.xml').read_bytes())


if __name__ == '__main__':
    if len(sys.argv) > 1 and sys.argv[1].endswith('.zip'):
        ZIP_PATH = Path(sys.argv.pop(1))
    if len(sys.argv) > 1 and sys.argv[1].endswith('.xbt'):
        XBT_PATH = Path(sys.argv.pop(1))
    unittest.main()
