#!/usr/bin/env python3
"""Focused offline installation/packaging checks. No running Kodi or providers.

Run: python3 scripts/skin_harness/test_installer_performance.py
Packaging tests run the real build script against a tiny temporary addon only.
"""
import importlib.util
import ast
import json
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import Mock, patch
import xml.etree.ElementTree as ET
import zipfile

REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))                       # the installer imports wcs.skin.compose / family


def make_xbt(names=('colors/white.png',)):
    header_size = 9 + len(names) * 304
    entries = []
    for index, name in enumerate(names):
        entries.append(name.encode().ljust(256, b'\0') + struct.pack('<II', 0, 1) +
                       struct.pack('<IIIQQIQ', 1, 1, 16, 4, 4, 0, header_size + index * 4))
    return b'XBTF2' + struct.pack('<I', len(names)) + b''.join(entries) + b'\xff' * (4 * len(names))


def make_skin(folder, version='2.0', archive=True):
    folder.mkdir(parents=True, exist_ok=True)
    (folder / 'addon.xml').write_text('''<addon id="skin.milionar" version="{}">
<extension point="xbmc.gui.skin"><res folder="xml" default="true" /></extension>
<extension point="xbmc.addon.metadata"><assets><icon>media/icon.png</icon>
<fanart>resources/fanart.png</fanart><screenshot>resources/screenshot-01.png</screenshot>
</assets></extension></addon>'''.format(version))
    for relative in ('xml/Home.xml', 'xml/Font.xml', 'xml/Includes.xml', 'media/icon.png',
                     'resources/fanart.png', 'resources/screenshot-01.png', 'fonts/font.ttf', 'LICENSE.txt'):
        path = folder / relative
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(b'<window />' if relative.endswith('.xml') else relative.encode())
    if archive:
        (folder / 'media/Textures.xbt').write_bytes(make_xbt())
        for relative in ('fonts/NotoSans-Regular.ttf', 'fonts/NotoSans-MilionarBrand.ttf', 'extras/backgrounds/primary.jpg',
                         'media/milionar/logo.png', 'media/milionar/logo-mark-v1-final.png'):
            (folder / relative).parent.mkdir(parents=True, exist_ok=True)
            (folder / relative).write_bytes(relative.encode())


class InstallerTests(unittest.TestCase):
    def setUp(self):
        temporary = tempfile.TemporaryDirectory()
        self.addCleanup(temporary.cleanup)
        self.home = Path(temporary.name)
        self.source = self.home / 'addon/resources/skin.milionar'
        self.target = self.home / 'addons/skin.milionar'
        self.target.parent.mkdir()
        make_skin(self.source, archive=False)
        keymap = self.home / 'addon/resources/keymaps/milionar-home.xml'
        keymap.parent.mkdir()
        keymap.write_text('<keymap />')
        self.monitor = Mock()
        self.monitor.abortRequested.return_value = False
        self.monitor.waitForAbort.return_value = False
        self.progress = Mock()
        self.dialog = Mock()
        modules = {name: types.ModuleType(name) for name in ('xbmc', 'xbmcgui', 'xbmcaddon', 'xbmcvfs')}
        self.xbmc = modules['xbmc']
        self.xbmc.LOGINFO, self.xbmc.LOGWARNING, self.xbmc.LOGERROR = 1, 2, 3
        self.xbmc.log = Mock()
        self.xbmc.Monitor = Mock(return_value=self.monitor)
        self.xbmc.executebuiltin = Mock()
        self.xbmc.getLocalizedString = lambda label: {33009: 'Copying', 24077: 'Verifying downloaded add-on...',
                                                    24086: 'Installing add-on...', 24084: 'Add-on installed'}[label]
        self.rpc_calls = []

        def rpc(request):
            method = json.loads(request)['method']
            self.rpc_calls.append(method)
            if method == 'Addons.GetAddonDetails':
                return json.dumps({'result': {'addon': {'enabled': False}}})
            return json.dumps({'result': 'OK'})

        self.xbmc.executeJSONRPC = Mock(side_effect=rpc)
        self.gui = modules['xbmcgui']
        self.gui.DialogProgressBG = Mock(return_value=self.progress)
        self.gui.Dialog = Mock(return_value=self.dialog)
        self.gui.NOTIFICATION_INFO, self.gui.NOTIFICATION_ERROR = 'info', 'error'
        modules['xbmcvfs'].translatePath = lambda path: str(self.home / path.replace('special://home/', '').replace(
            'special://profile/', 'profile/')) if path.startswith('special://') else path
        self.vfs = modules['xbmcvfs']
        self.native = self.home / 'system/Textures.xbt'
        self.native.parent.mkdir()
        self.native.write_bytes(make_xbt())

        estuary = self.home / 'system/estuary'
        for relative in ('fonts/NotoSans-Regular.ttf', 'extras/backgrounds/primary.jpg'):
            (estuary / relative).parent.mkdir(parents=True, exist_ok=True)
            (estuary / relative).write_bytes(relative.encode())
        logo = self.home / 'addon/resources/media/brand/milionar-mark.png'
        logo.parent.mkdir(parents=True, exist_ok=True)
        logo.write_bytes(b'logo')
        prefix = 'special://xbmc/addons/skin.estuary/'

        def native_copy(source, destination):
            self.assertTrue(source.startswith(prefix))
            if source == prefix + 'media/Textures.xbt':
                Path(destination).write_bytes(self.native.read_bytes())
            else:
                Path(destination).write_bytes((estuary / source[len(prefix):]).read_bytes())
            return True

        def native_listdir(path):
            folder = estuary / path[len(prefix):]
            return ([e.name for e in folder.iterdir() if e.is_dir()], [e.name for e in folder.iterdir() if e.is_file()])

        self.vfs.copy = Mock(side_effect=native_copy)
        self.vfs.listdir = native_listdir
        with patch.dict(sys.modules, modules):
            spec = importlib.util.spec_from_file_location('installer_under_test', REPO / 'wcs/skin/installer.py')
            self.installer = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(self.installer)
        self.addon = Mock()
        self.addon.getAddonInfo.side_effect = lambda name: str(self.home / 'addon') if name == 'path' else '1.0'
        self.addon.getLocalizedString.return_value = 'The Milionář skin could not be installed'
        self.staged, self.retired = map(Path, self.installer._staging_paths())

    def test_fresh_install_progress_completion_and_runtime_assets(self):
        result = self.installer.install(self.addon, self.monitor)
        self.assertTrue(result['copied'] and result['ready'])
        self.assertEqual(self.installer._validate_skin(self.target), '2.0')
        for path in self.source.rglob('*'):
            if path.is_file():
                self.assertEqual(path.read_bytes(), (self.target / path.relative_to(self.source)).read_bytes())
        percentages = [call.args[0] for call in self.progress.update.call_args_list]
        self.assertEqual(percentages, sorted(set(percentages)))
        self.assertEqual(percentages[-1], 100)
        self.progress.close.assert_called_once()
        self.dialog.notification.assert_called_once_with('skin.milionar', 'Add-on installed', 'info', 5000, False)
        self.assertIn('Addons.SetAddonEnabled', self.rpc_calls)

    def test_same_version_has_no_copy_progress_recovery_or_cleanup(self):
        make_skin(self.target)
        make_skin(self.retired, '1.0')
        with patch.object(self.installer, '_staging_paths', side_effect=AssertionError('unnecessary staging')):
            result = self.installer.install(self.addon, self.monitor)
        self.assertTrue(result['ready'])
        self.assertFalse(result['copied'])
        self.gui.DialogProgressBG.assert_not_called()
        self.assertTrue(self.retired.exists())

    def test_dev_symlink_untouched_even_with_pending_backup(self):
        self.target.symlink_to(self.source, target_is_directory=True)
        make_skin(self.retired, '1.0')
        with patch.object(self.installer, '_staging_paths', side_effect=AssertionError('dev recovery')):
            result = self.installer.install(self.addon, self.monitor)
        self.assertTrue(result['dev'] and result['ready'])
        self.assertFalse(result['copied'])
        self.assertTrue(self.target.is_symlink())
        self.gui.DialogProgressBG.assert_not_called()

    def test_update_retains_backup_and_does_not_delete_on_completion(self):
        make_skin(self.target, '1.0')
        with patch.object(self.installer.shutil, 'rmtree', wraps=shutil.rmtree) as cleanup:
            self.installer._swap_in(self.source, self.target)
        cleanup.assert_not_called()
        self.assertEqual(self.installer.read_version(self.retired), '1.0')
        self.assertEqual(self.installer.read_version(self.target), '2.0')

    def test_next_update_reclaims_only_previous_backup(self):
        make_skin(self.target, '1.0')
        make_skin(self.retired, '0.9')
        self.installer._swap_in(self.source, self.target)
        self.assertEqual(self.installer.read_version(self.retired), '1.0')
        self.assertEqual(self.installer.read_version(self.target), '2.0')

    def test_staging_walks_once_copies_each_file_once_and_bounds_progress(self):
        for index in range(120):
            (self.source / 'media/{}.png'.format(index)).write_bytes(b'image')
        expected = {path.relative_to(self.source) for path in self.source.rglob('*') if path.is_file()}
        with patch.object(self.installer.os, 'walk', wraps=os.walk) as walk, patch.object(
                self.installer.shutil, 'copyfile', wraps=shutil.copyfile) as copy, patch.object(
                self.installer.shutil, 'copystat', side_effect=AssertionError('unnecessary metadata writes')):
            self.installer._swap_in(self.source, self.target, self.monitor, self.progress)
        walk.assert_called_once()
        copied = [Path(call.args[0]).relative_to(self.source) for call in copy.call_args_list
                  if self.source in Path(call.args[0]).parents]
        self.assertEqual(len(copied), len(expected))
        self.assertEqual(set(copied), expected)
        self.assertEqual(copied[-1], Path('addon.xml'))
        self.assertLessEqual(self.progress.update.call_count, 92)

    def test_source_scan_error_preserves_installed_target(self):
        make_skin(self.target, '1.0')

        def failed_walk(source, onerror):
            onerror(PermissionError('unreadable source directory'))
            yield

        with patch.object(self.installer.os, 'walk', side_effect=failed_walk):
            with self.assertRaises(PermissionError):
                self.installer._swap_in(self.source, self.target)
        self.assertEqual(self.installer.read_version(self.target), '1.0')

    def test_invalid_staged_manifest_or_assets_never_moves_target(self):
        make_skin(self.target, '1.0')
        original = (self.source / 'addon.xml').read_text()
        invalid = ('<addon', original.replace('id="skin.milionar"', 'id="wrong"'),
                   original.replace('version="2.0"', ''), original.replace('xbmc.gui.skin', 'wrong'),
                   original.replace('folder="xml"', 'folder="../outside"'))
        for manifest in invalid:
            with self.subTest(manifest=manifest):
                (self.source / 'addon.xml').write_text(manifest)
                with self.assertRaises((ValueError, ET.ParseError)), patch.object(
                        self.installer.os, 'rename', side_effect=AssertionError('premature swap')):
                    self.installer._swap_in(self.source, self.target)
                self.assertEqual(self.installer.read_version(self.target), '1.0')
        (self.source / 'addon.xml').write_text(original)
        (self.source / 'xml/Home.xml').unlink()
        with self.assertRaises(ValueError):
            self.installer._swap_in(self.source, self.target)
        self.assertEqual(self.installer.read_version(self.target), '1.0')

    def test_copy_failure_has_no_installable_partial_stage_and_closes_progress(self):
        make_skin(self.target, '1.0')
        with patch.object(self.installer.shutil, 'copyfile', side_effect=OSError('disk full')):
            with self.assertRaises(OSError):
                self.installer.install(self.addon, self.monitor)
        self.assertFalse((self.staged / 'addon.xml').exists())
        self.assertEqual(self.installer.read_version(self.target), '1.0')
        self.progress.close.assert_called_once()
        self.assertEqual(self.dialog.notification.call_args.args[2], 'error')

    def test_abort_during_copy_preserves_target_without_success_notification(self):
        make_skin(self.target, '1.0')
        copyfile = shutil.copyfile

        def abort_after_file(*args):
            copyfile(*args)
            self.monitor.abortRequested.return_value = True

        with patch.object(self.installer.shutil, 'copyfile', side_effect=abort_after_file):
            result = self.installer.install(self.addon, self.monitor)
        self.assertFalse(result['ready'] or result['copied'])
        self.assertEqual(self.installer.read_version(self.target), '1.0')
        self.assertFalse((self.staged / 'addon.xml').exists())
        self.progress.close.assert_called_once()
        self.dialog.notification.assert_not_called()

    def test_failed_swap_rolls_back(self):
        make_skin(self.target, '1.0')
        rename = os.rename

        def fail_promotion(source, target):
            if Path(source) == self.staged:
                raise OSError('rename failed')
            rename(source, target)

        with patch.object(self.installer.os, 'rename', side_effect=fail_promotion):
            with self.assertRaises(OSError):
                self.installer._swap_in(self.source, self.target)
        self.assertEqual(self.installer.read_version(self.target), '1.0')
        self.assertFalse(self.retired.exists())

    def test_interruption_between_renames_restores_old_before_retry(self):
        class Interrupted(BaseException):
            pass

        make_skin(self.target, '1.0')
        rename = os.rename

        def interrupt(source, target):
            if Path(source) == self.staged:
                raise Interrupted()
            rename(source, target)

        with patch.object(self.installer.os, 'rename', side_effect=interrupt):
            with self.assertRaises(Interrupted):
                self.installer._swap_in(self.source, self.target)
        self.assertFalse(self.target.exists())
        self.assertTrue(self.installer._recover_swap(self.target))
        self.assertEqual(self.installer.read_version(self.target), '1.0')
        self.assertEqual(self.installer.read_version(self.staged), '2.0')
        self.assertTrue(self.installer.install(self.addon, self.monitor)['ready'])

    def test_recovery_preserves_valid_target_and_prefers_backup_for_invalid_target(self):
        make_skin(self.target, '2.0')
        make_skin(self.retired, '1.0')
        make_skin(self.staged, '3.0')
        self.assertFalse(self.installer._recover_swap(self.target))
        self.assertEqual(self.installer.read_version(self.target), '2.0')
        (self.target / 'addon.xml').write_text('<broken')
        self.assertTrue(self.installer._recover_swap(self.target))
        self.assertEqual(self.installer.read_version(self.target), '1.0')

    def test_first_install_recovers_complete_stage_but_rejects_partial_stage(self):
        make_skin(self.staged)
        (self.staged / 'addon.xml').unlink()
        self.assertFalse(self.installer._recover_swap(self.target))
        make_skin(self.staged)
        self.assertTrue(self.installer.install(self.addon, self.monitor)['ready'])
        self.assertEqual(self.installer.read_version(self.target), '2.0')
        self.gui.DialogProgressBG.assert_not_called()

    def test_recovery_still_restores_backup_with_missing_bundle(self):
        make_skin(self.retired, '1.0')
        (self.source / 'addon.xml').unlink()
        self.assertFalse(self.installer.install(self.addon, self.monitor)['ready'])
        self.assertEqual(self.installer.read_version(self.target), '1.0')

    def test_registration_failure_closes_progress_without_false_completion(self):
        with patch.object(self.installer, '_wait_known', return_value=None):
            result = self.installer.install(self.addon, self.monitor)
        self.assertFalse(result['ready'])
        self.assertNotIn(100, [call.args[0] for call in self.progress.update.call_args_list])
        self.progress.close.assert_called_once()
        self.assertEqual(self.dialog.notification.call_args.args[2], 'error')

    def test_enable_failure_reports_failure(self):
        with patch.object(self.installer, '_rpc', side_effect=[{'result': {'addon': {'enabled': False}}},
                                                             {'error': {'message': 'failed'}}]):
            result = self.installer.install(self.addon, self.monitor)
        self.assertFalse(result['ready'])
        self.assertEqual(self.dialog.notification.call_args.args[2], 'error')

    def test_ready_clears_copy_gate_before_waiting_for_home_offer(self):
        home = Mock()
        self.gui.Window = Mock(return_value=home)
        self.xbmc.getSkinDir = Mock(return_value='skin.estuary')

        def quiet(monitor):
            home.clearProperty.assert_called_once_with('milionar.skin.installing')
            return False

        with patch.object(self.installer, 'install', return_value={'ready': True, 'dev': False, 'copied': False}), patch.object(
                self.installer, '_wait_for_quiet_gui', side_effect=quiet) as wait:
            self.installer.run(self.addon)
        wait.assert_called_once()
        home.setProperty.assert_not_called()
        self.dialog.yesno.assert_not_called()

    def test_shared_logo_is_validated_without_copying_it_into_skin(self):
        uri = 'special://home/addons/plugin.video.milionar/resources/media/brand/milionar-mark.png'
        shared = Path(self.installer.xbmcvfs.translatePath(uri))
        shared.parent.mkdir(parents=True)
        shared.write_bytes(b'logo')
        manifest = ET.parse(self.source / 'addon.xml')
        manifest.find('.//assets/icon').text = '../plugin.video.milionar/resources/media/brand/milionar-mark.png'
        manifest.write(self.source / 'addon.xml')
        self.assertEqual(self.installer._validate_skin(self.source), '2.0')
        valid = manifest.find('.//assets/icon').text
        for invalid in (uri, '../other-addon/resources/media/brand/milionar-mark.png',
                        '../plugin.video.milionar/resources/media/brand/other.png'):
            manifest.find('.//assets/icon').text = invalid
            manifest.write(self.source / 'addon.xml')
            with self.assertRaises(ValueError):
                self.installer._validate_skin(self.source)
        manifest.find('.//assets/icon').text = valid
        manifest.write(self.source / 'addon.xml')
        shared.unlink()
        with self.assertRaisesRegex(ValueError, 'missing shared skin logo'):
            self.installer._validate_skin(self.source)

    def test_active_update_enqueues_reload_before_clearing_copy_gate(self):
        home = Mock()
        self.gui.Window = Mock(return_value=home)
        self.xbmc.getSkinDir = Mock(return_value='skin.milionar')

        def enqueue(command):
            self.assertEqual(command, 'ReloadSkin()')
            home.clearProperty.assert_not_called()

        self.xbmc.executebuiltin.side_effect = enqueue
        with patch.object(self.installer, 'install', return_value={'ready': True, 'dev': False, 'copied': True}):
            self.installer.run(self.addon)
        self.xbmc.executebuiltin.assert_called_once_with('ReloadSkin()')
        home.clearProperty.assert_called_once_with('milionar.skin.installing')

    def test_switch_offer_waits_for_modal_and_pending_migration(self):
        home = Mock()
        self.gui.Window = Mock(return_value=home)
        visible = {'Window.IsActive(home)': True, 'Player.HasVideo': False, 'System.HasActiveModalDialog': True}
        self.xbmc.getCondVisibility = lambda condition: visible[condition]
        self.monitor.waitForAbort.return_value = True
        home.getProperty.return_value = ''
        self.assertFalse(self.installer._wait_for_quiet_gui(self.monitor))
        visible['System.HasActiveModalDialog'] = False
        home.getProperty.return_value = '1'
        self.assertFalse(self.installer._wait_for_quiet_gui(self.monitor))
        home.getProperty.return_value = ''
        self.assertTrue(self.installer._wait_for_quiet_gui(self.monitor))


class PackagingTests(unittest.TestCase):
    def setUp(self):
        temporary = tempfile.TemporaryDirectory()
        self.addCleanup(temporary.cleanup)
        self.addon = Path(temporary.name) / 'addon'
        self.estuary = Path(temporary.name) / 'estuary'
        self.output = Path(temporary.name) / 'output with spaces'
        make_skin(self.addon / 'resources/skin.milionar')
        self.write('addon.xml', '<addon version="1.2" id="plugin.video.milionar"><extension point="xbmc.python.pluginsource" library="main.py" /><extension point="xbmc.addon.metadata"><assets><icon>resources/icon.png</icon><screenshot>resources/preview.png</screenshot></assets></extension></addon>')
        self.write('main.py', "action = 'normal'\nif action == 'normal':\n    result = 1\nelif action == 'mplay_dev_list':\n    from wcs.library2 import dev_list\n    dev_list.run()\nelif action == 'other':\n    result = 2\n")
        self.write('service.py', '"""Service docstring."""\ndef run_dev_harness():\n    from wcs.devtools import harness_service\n    harness_service.run()\ndef startup():\n    run_dev_harness()\n    return 42\n')
        self.write('wcs/library2/resolver.py', 'from wcs.library2 import fixture_provider, play\ndef _stream_for(provider, ident):\n    if provider == "fixture":\n        return ident\n    if provider == "kraska":\n        return "normal"\n    return "fallback"\ndef resolve():\n    stream = None\n    if fixture_provider.enabled():\n        stream = fixture_provider.file_for()\n    if not stream:\n        stream = play.resolve()\n    return stream\n')
        self.write('wcs/ui/assets.py', "NATIVE_MAP = {'white.png': 'colors/white.png', 'ai_card_movies.png': 'DefaultMovies.png'}\nLOGO_ALIASES = ('logo.png', 'milionar-mark.png')\ndef native(name):\n    return 'xbt://archive/' + NATIVE_MAP.get(name, name)\n")
        self.write('wcs/ai/DialogAIRecommendationHub.py', "cards = {'icon': 'ai_card_movies.png'}\nposter = f\"special://home/addons/plugin.video.milionar/resources/media/{cards['icon']}\"\n")
        self.write('wcs/ui/example.py', "from __future__ import annotations\nicon = 'special://home/addons/plugin.video.milionar/resources/media/white.png'\ncache = 'special://profile/cache/chips_001.png'\n")
        self.write('resources/skins/Default/1080i/example.xml', '<window><control type="image" id="9"><texture diffuse="special://home/addons/plugin.video.milionar/resources/media/card_mask.png" scalediffuse="false">special://home/addons/plugin.video.milionar/resources/media/white.png</texture></control><texture>$INFO[ListItem.Rating,flags/starrating/,.png]</texture></window>')
        self.write('resources/settings.xml', '<settings><category><setting id="dev_fixture_provider"/><setting id="real_setting"/></category></settings>')
        self.write('resources/media/brand/milionar-mark.png', b'logo')
        self.write('wcs/library2/play.py', 'def resolve():\n    return 7\n')
        for relative in ('wcs/library2/dev_list.py', 'wcs/library2/fixture_provider.py', 'wcs/devtools/__init__.py', 'resources/media/generate_textures.py', 'scripts/fixture.py', '.agents/rules/example.md', 'docs/audit.md', 'resources/skins/Default/1080i/devtools/example.xml', 'resources/media/screenshots/home.png', 'resources/fonts/NotoSans-Regular.ttf'):
            self.write(relative, b'dev')
        self.write('wcs/games/moderator_styles/runtime.md', 'Runtime prompt')
        self.write('resources/skin.milionar/fonts/noto_license.txt', 'Required license')
        for locale in ('cs_cz', 'sk_sk', 'en_gb', 'fr_fr'):
            self.write('resources/skin.milionar/language/resource.language.' + locale + '/strings.po',
                       'Full source language: ' + locale)
        self.write('resources/language/resource.language.fr_fr/strings.po', 'Plugin language untouched')
        self.write('scripts/build_zip.sh', (REPO / 'scripts/build_zip.sh').read_bytes())
        bundle = self.estuary / 'media/Textures.xbt'
        bundle.parent.mkdir(parents=True)
        names = ('colors/white.png', 'flags/starrating/0.png', 'DefaultMovies.png')
        bundle.write_bytes(b'XBTF2' + struct.pack('<I', len(names)) + b''.join(
            name.encode().ljust(256, b'\0') + struct.pack('<II', 0, 0) for name in names))
        self.before = {path.relative_to(self.addon): path.read_bytes() for path in self.addon.rglob('*') if path.is_file()}

    def write(self, relative, value):
        path = self.addon / relative
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(value.encode() if isinstance(value, str) else value)

    def build(self, check=True):
        return subprocess.run(['sh', str(self.addon / 'scripts/build_zip.sh'), str(self.output), str(self.estuary)],
                              check=check, capture_output=True, text=True)

    def test_runtime_zip_transforms_only_release_bytes(self):
        result = self.build()
        archive_path = Path(result.stdout.strip())
        prefix = 'plugin.video.milionar/'
        with zipfile.ZipFile(archive_path) as archive:
            self.assertIsNone(archive.testzip())
            names = [name[len(prefix):] for name in archive.namelist()]
            self.assertTrue(all(name.startswith(prefix) for name in archive.namelist()))
            # runtime images ship unchanged; what the installer fills in (Estuary fonts/extras/textures, logo copies),
            # store art, screenshots and unused fonts do not
            self.assertEqual(sorted(name for name in names if name.endswith(('.png', '.jpg', '.ttf', '.xbt'))),
                             ['resources/media/brand/milionar-mark.png', 'resources/skin.milionar/media/Textures.xbt',
                              'resources/skin.milionar/media/icon.png'])  # the fixture skin ships its own xbt
            self.assertFalse(any('devtools' in name or 'fixture' in name or 'dev_list' in name or name.startswith(('docs/', 'scripts/', '.agents/')) for name in names))
            self.assertIn('resources/skin.milionar/LICENSE.txt', names)
            self.assertIn('resources/skin.milionar/fonts/noto_license.txt', names)
            self.assertIn('wcs/games/moderator_styles/runtime.md', names)
            for locale in ('cs_cz', 'sk_sk', 'en_gb'):
                language = 'resources/skin.milionar/language/resource.language.' + locale + '/strings.po'
                self.assertEqual(archive.read(prefix + language), self.before[Path(language)])
            self.assertNotIn('resources/skin.milionar/language/resource.language.fr_fr/strings.po', names)
            self.assertIn('resources/language/resource.language.fr_fr/strings.po', names)
            self.assertEqual(archive.read(prefix + 'wcs/ui/assets.py'),
                             self.before[Path('wcs/ui/assets.py')])
            for manifest in ('addon.xml', 'resources/skin.milionar/addon.xml'):
                xml = ET.fromstring(archive.read(prefix + manifest))
                self.assertEqual(xml.findall('.//screenshot') + xml.findall('.//fanart'), [])
                self.assertEqual(xml.find("requires/import[@addon='skin.estuary']").get('version'), '4.0.0')
                icon = xml.find('.//assets/icon').text
                if manifest == 'addon.xml':
                    self.assertEqual(icon, 'resources/media/brand/milionar-mark.png')
                    self.assertIsNone(xml.find("requires/import[@addon='plugin.video.milionar']"))
                else:
                    self.assertEqual(icon, '../plugin.video.milionar/resources/media/brand/milionar-mark.png')
                    self.assertEqual(xml.find("requires/import[@addon='plugin.video.milionar']").get('version'), '1.2')
            service = archive.read(prefix + 'service.py').decode()
            self.assertNotIn('run_dev_harness', service)
            namespace = {}
            exec(service, namespace)
            self.assertEqual(namespace['startup'](), 42)
            main = archive.read(prefix + 'main.py').decode()
            self.assertNotIn('mplay_dev_list', main)
            self.assertIn("elif action == 'other':", main)
            resolver = archive.read(prefix + 'wcs/library2/resolver.py').decode()
            self.assertNotIn('fixture_provider', resolver)
            self.assertNotIn('fixture', resolver)
            stream = next(node for node in ast.parse(resolver).body
                          if isinstance(node, ast.FunctionDef) and node.name == '_stream_for')
            namespace = {}
            exec(compile(ast.Module(body=[stream], type_ignores=[]), 'staged_resolver.py', 'exec'), namespace)
            self.assertEqual(namespace['_stream_for']('fixture', '/fixture/file'), 'fallback')
            self.assertEqual(namespace['_stream_for']('kraska', 'ident'), 'normal')
            self.assertIn('if not stream:', resolver)
            for name in names:
                if name.endswith('.py'):
                    compile(archive.read(prefix + name), name, 'exec')
            xml = ET.fromstring(archive.read(prefix + 'resources/skins/Default/1080i/example.xml'))
            texture = xml.find('.//control/texture')
            self.assertEqual(texture.get('scalediffuse'), 'false')
            self.assertEqual(texture.get('diffuse'), 'special://home/addons/plugin.video.milionar/resources/media/card_mask.png')
            self.assertEqual(texture.text, 'special://home/addons/plugin.video.milionar/resources/media/white.png')
            self.assertEqual(xml.find('./texture').text,
                             '$INFO[ListItem.Rating,flags/starrating/,.png]')
            self.assertIn('flags/starrating/', xml.find('./texture').text)
            self.assertIsNone(ET.fromstring(archive.read(prefix + 'resources/settings.xml')).find(".//setting[@id='dev_fixture_provider']"))
            source = archive.read(prefix + 'wcs/ui/example.py').decode()
            self.assertEqual(source, self.before[Path('wcs/ui/example.py')].decode())
            self.assertIn('special://profile/cache/chips_001.png', source)
            cards = archive.read(prefix + 'wcs/ai/DialogAIRecommendationHub.py').decode()
            self.assertEqual(cards, self.before[Path('wcs/ai/DialogAIRecommendationHub.py')].decode())
        self.assertEqual(self.before, {path.relative_to(self.addon): path.read_bytes() for path in self.addon.rglob('*') if path.is_file()})
        self.assertEqual(list(self.output.iterdir()), [archive_path])

    def test_bad_import_or_asset_preserves_previous_archive(self):
        archive_path = Path(self.build().stdout.strip())
        previous = archive_path.read_bytes()
        for relative, value in (('wcs/leftover.py', 'from wcs.library2 import fixture_provider\n'),
                                ('resources/skins/Default/1080i/bad.xml', '<window><texture>special://home/addons/plugin.video.milionar/resources/media/screenshots/home.png</texture></window>')):
            with self.subTest(relative=relative):
                self.write(relative, value)
                result = self.build(check=False)
                self.assertNotEqual(result.returncode, 0)
                self.assertEqual(archive_path.read_bytes(), previous)
                self.assertEqual(list(self.output.iterdir()), [archive_path])
                (self.addon / relative).unlink()

    def test_staged_real_helper_imports_without_circular_initialization(self):
        source = (REPO / 'wcs/ui/assets.py').read_bytes()
        self.write('wcs/ui/assets.py', source)
        mapping = next(ast.literal_eval(node.value) for node in ast.parse(source).body
                       if isinstance(node, ast.Assign) and node.targets[0].id == 'NATIVE_MAP')
        names = sorted(set(mapping.values()) | {'flags/starrating/0.png'})
        (self.estuary / 'media/Textures.xbt').write_bytes(
            b'XBTF2' + struct.pack('<I', len(names)) + b''.join(
                name.encode().ljust(256, b'\0') + struct.pack('<II', 0, 0) for name in names))
        with zipfile.ZipFile(Path(self.build().stdout.strip())) as archive:
            packaged = archive.read('plugin.video.milionar/wcs/ui/assets.py')
        self.assertEqual(packaged, source)
        vfs = types.ModuleType('xbmcvfs')
        vfs.translatePath = lambda value: '/Kodi with spaces/Textures.xbt'
        namespace = {'__name__': 'staged_assets'}
        with patch.dict(sys.modules, {'xbmcvfs': vfs}):
            exec(compile(packaged, 'staged_assets.py', 'exec'), namespace)
        self.assertEqual(namespace['NATIVE_MAP'], mapping)
        self.assertEqual(namespace['native']('white.png'),
                         'xbt://%2FKodi%20with%20spaces%2FTextures.xbt/colors/white.png')
        self.assertEqual(namespace['native']('milionar/logo.png'), namespace['LOGO_PATH'])


if __name__ == '__main__':
    unittest.main(verbosity=2)
