#!/bin/sh
# Runtime-only release. Optional second argument: installed skin.estuary directory.
# All rewrites affect ZIP bytes only; source artwork and development code stay intact.
set -e
ADDON_DIR="$(cd "$(dirname "$0")/.." && pwd)"
python3 - "$ADDON_DIR" "${1:-/tmp}" "${2:-}" <<'PY'
import ast
import os
from pathlib import Path
import re
import struct
import sys
import tempfile
import xml.etree.ElementTree as ET
import zipfile

source, output_dir = map(Path, map(os.path.abspath, sys.argv[1:3]))
candidates = [Path(sys.argv[3])] if sys.argv[3] else [
    source.parent / 'skin.estuary',
    Path('/Applications/Kodi.app/Contents/Resources/Kodi/addons/skin.estuary'),
    Path('/usr/share/kodi/addons/skin.estuary')]
estuary = next((path for path in candidates if (path / 'media/Textures.xbt').is_file()), None)
if estuary is None:
    raise ValueError('provide the installed skin.estuary directory as the second argument')

# Read only the XBTF2 directory, never unpack/copy the system archive.
native_names = {}
with (estuary / 'media/Textures.xbt').open('rb') as bundle:
    if bundle.read(5) != b'XBTF2':
        raise ValueError('expected Kodi 21 XBTF2 texture bundle')
    count, = struct.unpack('<I', bundle.read(4))
    for _ in range(count):
        name = bundle.read(256).split(b'\0', 1)[0].decode('utf-8')
        loop, frames = struct.unpack('<II', bundle.read(8))
        native_names[name.lower()] = name
        bundle.seek(frames * 40, 1)

root = ET.parse(source / 'addon.xml').getroot()
addon_id, version = root.attrib['id'], root.attrib['version']
if not all(re.fullmatch(r'[A-Za-z0-9_.-]+', value) for value in (addon_id, version)):
    raise ValueError('invalid addon id/version for ZIP filename')
output = output_dir / '{}-{}.zip'.format(addon_id, version)
logo = 'resources/media/brand/milionar-mark.png'
addon_base = 'special://home/addons/' + addon_id + '/'
logo_uri = addon_base + logo
prefix = '$INFO[Window(Home).Property(milionar.assets.xbt)]'

# Explicit semantic substitutes for custom artwork; remaining geometry fills
# intentionally use native white. Custom rounded diffuse masks are removed.
substitutes = {
    'placeholder_tv_card.png': 'DefaultTVShows.png',
    'placeholder_movie_card.png': 'DefaultMovies.png',
    'placeholder_poster.png': 'DefaultVideo.png', 'poster_placeholder.png': 'DefaultVideo.png',
    'plus_card.png': 'DefaultAddSource.png', 'icon_ai_sparkle.png': 'DefaultMusicSearch.png',
    'icon_trash.png': 'icons/infodialogs/uninstall.png', 'checkmark.png': 'icons/infodialogs/enabled.png',
    'notif_icon_close.png': 'dialogs/close.png', 'search_icon_close.png': 'dialogs/close.png',
    'notif_icon_play.png': 'icons/infodialogs/play.png',
    'search_play_focus.png': 'icons/infodialogs/play.png', 'search_play_idle.png': 'icons/infodialogs/play.png',
    'ai_card_movies.png': 'DefaultMovies.png', 'ai_card_series.png': 'DefaultTVShows.png',
    'ai_card_mix.png': 'DefaultVideo.png', 'ai_card_actors.png': 'DefaultActor.png',
    'ai_card_directors.png': 'DefaultDirector.png', 'ai_card_collections.png': 'DefaultSets.png',
    'ai_card_history.png': 'DefaultVideoPlaylists.png',
    'card_ai_recommend.png': 'DefaultMusicSearch.png', 'card_my_library.png': 'DefaultFavourites.png',
    'card_search_tmdb.png': 'DefaultAddonsSearch.png', 'card_tmdb_recommend.png': 'DefaultMovies.png',
    'icons/Games.png': 'DefaultAddonGame.png', 'icons/TVShows.png': 'DefaultTVShows.png',
    'common/black.png': 'colors/black.png', 'common/arrow_right.png': 'overlays/arrowright.png',
    'none.png': 'colors/black.png', 'DefaultSearch.png': 'DefaultAddonsSearch.png',
    'DefaultAddonPVRClient.png': 'DefaultAddonPVRClient.png',
    'DefaultProgram.png': 'DefaultAddonProgram.png',
}
brand_names = {'milionar-mark.png', 'Milionar.png', 'milionar_sidebar_header.png',
               'logo-mark-v1-final.png', 'logo.png'}


def native(name):
    key = name.lower()
    if key not in native_names:
        raise ValueError('texture absent from system Estuary: ' + name)
    return native_names[key]


def replacement(value):
    """Return a native name, canonical logo URI, or None for non-local content."""
    if value == logo_uri or value == logo:
        return logo_uri
    if value.startswith(addon_base + 'resources/media/'):
        name = value[len(addon_base + 'resources/media/'):]
    elif value.startswith('special://skin/extras/'):
        return native('dialogs/dialog-bg-nobo.png')
    elif value.lower() in native_names or value in substitutes:
        name = value
    elif value.startswith('milionar/'):
        name = value
    elif value.startswith('ai_card_') and value.endswith('.png'):
        name = value
    elif (source / 'resources/media' / value).is_file() and value.lower().endswith(('.png', '.jpg')):
        name = value
    else:
        return None
    if Path(name).name in brand_names:
        return logo_uri
    if name.lower() in native_names:
        return native(name)
    if name in substitutes:
        return native(substitutes[name])
    if any(word in name.lower() for word in ('background', 'fanart', 'hub_', 'panel_bg', 'dialog_rounded')):
        return native('dialogs/dialog-bg-nobo.png')
    if name.startswith('gradient_'):
        return native('overlays/overlayfade.png')
    if any(word in name.lower() for word in ('btn_', 'button', 'pill', 'ladder')):
        return native('buttons/dialogbutton-fo.png')
    if any(word in name.lower() for word in ('border', 'focus', 'stroke')):
        return native('buttons/thumbnail_focused.png')
    return native('colors/white.png')


excluded_names = {'.git', '.agents', '.claude', '.gitignore', 'docs', 'AGENTS.md', 'scripts', '__pycache__',
                  '.DS_Store', '.idea', '.vscode', 'tests', 'fixtures', 'devtools', 'repository'}
excluded_modules = {'wcs/library2/dev_list.py', 'wcs/library2/fixture_provider.py'}
skin_languages = {'resource.language.cs_cz', 'resource.language.sk_sk', 'resource.language.en_gb'}
binary_suffixes = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.ttf', '.otf', '.xbt'}


def legal(path):
    return any(word in path.name.lower() for word in ('license', 'licence', 'copying', 'copyright'))


def excluded(relative):
    path = Path(relative)
    return (path.name in excluded_names or path.suffix in ('.pyc', '.pyo') or
            relative in excluded_modules or
            (relative.startswith('resources/skin.milionar/language/') and len(path.parts) > 3 and
             path.parts[3] not in skin_languages and not legal(path)) or
            (relative.startswith('resources/media/') and path.suffix == '.py') or
            relative.startswith(('resources/fonts/', 'resources/media/screenshots/')) or
            relative.startswith('resources/skin.milionar/resources/') or
            (relative.startswith('resources/skin.milionar/fonts/') and path.suffix.lower() in ('.ttf', '.otf', '.jpg')) or
            (relative.startswith('resources/skin.milionar/extras/') and path.suffix.lower() in ('.jpg', '.jpeg')) or
            relative.startswith('resources/skin.milionar/media/milionar/logo') or
            (relative.startswith('resources/skin.milionar/media/') and
             relative[len('resources/skin.milionar/media/'):].lower() in native_names))


# Inventory once. Retain legal text even below otherwise asset-only directories.
files = {}
for folder, directories, names in os.walk(source, onerror=lambda error: (_ for _ in ()).throw(error)):
    relative_folder = Path(folder).relative_to(source)
    directories[:] = sorted(name for name in directories if name not in excluded_names and
                            (relative_folder != Path('.') or name in ('wcs', 'resources')))
    for name in sorted(names):
        path = Path(folder) / name
        relative = path.relative_to(source).as_posix()
        if relative_folder == Path('.') and name not in ('addon.xml', 'main.py', 'service.py', 'context.py', 'md5crypt.py') and not legal(path):
            continue
        if not excluded(relative) and path != output:
            files[relative] = path
if logo not in files:
    raise ValueError('missing canonical release logo: ' + logo)

# Ship only the images that some shipped code or XML actually names (wcs/ui/assets.py is a mapping table, not a user).
referenced = ''
for relative, path in files.items():
    if path.suffix in ('.py', '.xml') and relative != 'wcs/ui/assets.py':
        referenced += path.read_text(encoding='utf-8', errors='ignore')
unused = sorted(relative for relative in files
                if relative.startswith('resources/media/') and Path(relative).suffix.lower() in ('.png', '.jpg') and
                relative != logo and Path(relative).name not in referenced)
for relative in unused:
    del files[relative]
if unused:
    print('left out (not referenced): ' + ', '.join(unused), file=sys.stderr)


def splice(data, edits):
    for start, end, replacement_bytes in sorted(edits, reverse=True):
        data = data[:start] + replacement_bytes + data[end:]
    return data


def python_release(relative, data):
    """AST locations select release-only edits; unrelated source bytes survive."""
    tree = ast.parse(data, relative)
    lines = data.splitlines(keepends=True)
    starts = [0]
    for line in lines:
        starts.append(starts[-1] + len(line))
    edits = []
    for node in ast.walk(tree):
        remove = False
        if relative == 'service.py':
            remove = isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'run_dev_harness'
            remove |= (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) and
                       isinstance(node.value.func, ast.Name) and node.value.func.id == 'run_dev_harness')
        if relative == 'main.py' and isinstance(node, ast.If) and isinstance(node.test, ast.Compare):
            if any(isinstance(value, ast.Constant) and value.value == 'mplay_dev_list' for value in node.test.comparators):
                # An elif node spans subsequent siblings in AST; remove its own body only.
                edits.append((starts[node.lineno - 1], starts[node.body[-1].end_lineno], b''))
        if relative == 'wcs/library2/resolver.py':
            if (isinstance(node, ast.If) and isinstance(node.test, ast.Compare) and
                    isinstance(node.test.left, ast.Name) and node.test.left.id == 'provider' and
                    len(node.test.ops) == 1 and isinstance(node.test.ops[0], ast.Eq) and
                    isinstance(node.test.comparators[0], ast.Constant) and
                    node.test.comparators[0].value == 'fixture'):
                if node.orelse:
                    raise ValueError('fixture provider bypass has an unexpected else: ' + relative)
                remove = True
            if isinstance(node, ast.ImportFrom) and node.module == 'wcs.library2' and any(a.name == 'fixture_provider' for a in node.names):
                node.names = [alias for alias in node.names if alias.name != 'fixture_provider']
                edits.append((starts[node.lineno - 1], starts[node.end_lineno],
                              (ast.unparse(node) + '\n').encode() if node.names else b''))
            if isinstance(node, ast.If) and isinstance(node.test, ast.Call) and isinstance(node.test.func, ast.Attribute):
                function = node.test.func
                if isinstance(function.value, ast.Name) and function.value.id == 'fixture_provider' and function.attr == 'enabled':
                    if node.orelse:
                        raise ValueError('fixture branch has an unexpected else: ' + relative)
                    remove = True
        if remove:
            edits.append((starts[node.lineno - 1], starts[node.end_lineno], b''))
    data = splice(data, edits)
    compile(data, relative, 'exec')
    return data


# The two skin manifests: skin.milionar and its modern look, composed over it on the device (wcs/skin/compose.py).
skin_manifests = {'resources/skin.milionar/addon.xml': 'skin.milionar',
                  'resources/skin.milionar.modern/addon.xml': 'skin.milionar.modern'}


def xml_release(relative, data):
    root = ET.fromstring(data, parser=ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)))
    if relative == 'addon.xml' or relative in skin_manifests:
        requires = root.find('requires')
        if requires is None:
            requires = ET.Element('requires')
            root.insert(0, requires)
        dependency = requires.find("import[@addon='skin.estuary']")
        if dependency is None:
            dependency = ET.SubElement(requires, 'import', addon='skin.estuary')
        dependency.set('version', '4.0.0')
        for assets in root.findall("extension[@point='xbmc.addon.metadata']/assets"):
            for node in list(assets):
                if node.tag in ('screenshot', 'fanart'):
                    assets.remove(node)
                elif node.tag == 'icon':
                    node.text = logo if relative == 'addon.xml' else '../' + addon_id + '/' + logo
        if relative != 'addon.xml':
            plugin = requires.find("import[@addon='" + addon_id + "']")
            if plugin is None:
                plugin = ET.SubElement(requires, 'import', addon=addon_id)
            plugin.set('version', version)
    for parent in root.iter():
        for node in list(parent):
            if relative == 'resources/settings.xml' and node.tag == 'setting' and node.get('id') == 'dev_fixture_provider':
                parent.remove(node)
    return ET.tostring(root, encoding='utf-8', xml_declaration=True)


# Preflight syntax, declared libraries/assets and internal imports before replacing
# the last good ZIP. No Kodi module or provider is imported/executed here.
missing = set()
rewritten = {}
for relative, path in files.items():
    if path.suffix == '.py':
        rewritten[relative] = python_release(relative, path.read_bytes())
    elif relative in ('addon.xml', 'resources/settings.xml') or relative in skin_manifests:
        rewritten[relative] = xml_release(relative, path.read_bytes())
    elif path.suffix == '.xml':
        rewritten[relative] = path.read_bytes()  # shipped byte for byte

for relative, data in rewritten.items():
    if relative.endswith('.py'):
        tree = ast.parse(data, relative)
        for node in ast.walk(tree):
            if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'join':
                if any(isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute) and
                       isinstance(child.func.value, ast.Name) and child.func.value.id == '_release_assets' and
                       child.func.attr == 'native' for child in ast.walk(node)):
                    raise ValueError('native URI used as a joined path fragment: ' + relative)
            modules = []
            if isinstance(node, ast.Import):
                modules = [alias.name for alias in node.names]
            elif isinstance(node, ast.ImportFrom) and node.module:
                modules = [node.module] + [node.module + '.' + alias.name for alias in node.names]
            for module in modules:
                candidate = module.replace('.', '/') + '.py'
                if (source / candidate).is_file() and candidate not in files:
                    raise ValueError('excluded runtime import: {} -> {}'.format(relative, candidate))
                candidate = module.replace('.', '/') + '/__init__.py'
                if (source / candidate).is_file() and candidate not in files:
                    raise ValueError('excluded runtime package: {} -> {}'.format(relative, candidate))
            if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value.startswith(addon_base):
                candidate = node.value[len(addon_base):]
                if candidate.startswith(('resources/media/', 'resources/fonts/')) and not candidate.endswith('/') and candidate not in files:
                    if (source / candidate).exists():
                        raise ValueError('packaged Python asset left out of the release: {} -> {}'.format(relative, candidate))
                    missing.add(candidate)
    else:
        tree = ET.fromstring(data)
        for node in tree.iter():
            if node.tag == 'include' and node.get('file'):
                candidate = (Path(relative).parent / node.get('file')).as_posix()
                if candidate not in files:
                    raise ValueError('missing XML include: {} -> {}'.format(relative, candidate))
            for value in [node.text or ''] + list(node.attrib.values()):
                if re.search(r'\$INFO\[.*?,\$INFO\[Window\(Home\)\.Property\(milionar\.assets\.xbt\)\]', value):
                    raise ValueError('nested native INFO prefix: ' + relative)
                for candidate in re.findall(re.escape(addon_base) + r'([^,)\s\]]+\.(?:png|jpg|ttf))', value):
                    if candidate not in files:
                        if (source / candidate).exists():
                            raise ValueError('packaged XML asset left out of the release: {} -> {}'.format(relative, candidate))
                        missing.add(candidate)  # absent in the working tree too: renders the same as in development
                for candidate in re.findall(re.escape(prefix) + r'([^,$\[\]\s]+\.png)', value):
                    native(candidate)
        if relative.endswith('addon.xml'):
            if not tree.get('id') or not tree.get('version') or tree.tag != 'addon':
                raise ValueError('invalid packaged manifest: ' + relative)
            if relative in skin_manifests and tree.get('id') != skin_manifests[relative]:
                raise ValueError('unexpected skin addon id: ' + relative)
            for node in tree.iter():
                if node.get('library') and node.get('library') not in files and relative == 'addon.xml':
                    raise ValueError('missing declared library: ' + node.get('library'))
            for asset in tree.findall("extension[@point='xbmc.addon.metadata']/assets/*"):
                candidate = asset.text or ''
                sibling = '../' + addon_id + '/'  # the installed skin sits next to the plugin
                candidate = (candidate[len(addon_base):] if candidate.startswith(addon_base) else
                             candidate[len(sibling):] if candidate.startswith(sibling) else
                             (Path(relative).parent / candidate).as_posix())
                if candidate not in files:
                    raise ValueError('missing declared metadata asset: ' + candidate)
            # the modern overlay ships only what differs: its window XML comes from the classic skin
            skin_root = Path('resources/skin.milionar') if relative in skin_manifests else Path(relative).parent
            for res in tree.findall("extension[@point='xbmc.gui.skin']/res"):
                for name in ('Home.xml', 'Font.xml', 'Includes.xml'):
                    if (skin_root / res.get('folder', '') / name).as_posix() not in files:
                        raise ValueError('missing declared skin XML: ' + name)

for candidate in sorted(missing):
    print('WARNING referenced but absent in the source tree: ' + candidate, file=sys.stderr)

# Bytecode (docs/plan/skin-optimization, part E). Kodi's Python writes none, so without these every plugin run compiles
# every module it imports again. The files are hash-checked: Python compares each with its source on every import and
# ignores one that does not belong to it, so a stale file can never run. They are made from the bytes the ZIP ships, by
# the Python version Kodi 21 bundles (Android, macOS, Windows) and at the optimization level Kodi runs Python with
# (-O: it looks for <module>.cpython-311.opt-1.pyc, checked in the running Kodi 21.3). Any other Python does not look at
# them, and the service makes its own on the device (wcs/skin/bytecode.py).
BYTECODE_PYTHON, BYTECODE_TAG = 'python3.11', 'cpython-311.opt-1'
BYTECODE_SCRIPT = r'''
import importlib.util, marshal, os, py_compile, sys
root = sys.argv[1]
for folder, _directories, names in os.walk(root):
    for name in names:
        if name.endswith('.py'):
            path = os.path.join(folder, name)
            cached = py_compile.compile(path, doraise=True, dfile=os.path.relpath(path, root), optimize=1,
                                        invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH)
            with open(cached, 'rb') as handle:
                data = handle.read()
            with open(path, 'rb') as handle:
                source = handle.read()
            if data[:4] != importlib.util.MAGIC_NUMBER or data[4:8] != b'\x03\x00\x00\x00' \
                    or data[8:16] != importlib.util.source_hash(source):
                raise SystemExit('bad bytecode header: ' + path)
            marshal.loads(data[16:])
print(sys.implementation.cache_tag + '.opt-1')
'''


def bytecode(sources):
    """{'<folder>/__pycache__/<module>.<tag>.pyc': bytes} of the given {relative path: source bytes}."""
    import shutil
    import subprocess
    python = shutil.which(BYTECODE_PYTHON) or '/opt/homebrew/bin/' + BYTECODE_PYTHON
    if not os.path.exists(python):
        raise ValueError('{} is needed to ship bytecode for Kodi (brew install python@3.11)'.format(BYTECODE_PYTHON))
    result = {}
    with tempfile.TemporaryDirectory() as folder:
        for relative, data in sources.items():
            target = Path(folder) / relative
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_bytes(data)
        made = subprocess.run([python, '-c', BYTECODE_SCRIPT, folder], check=True, capture_output=True, text=True)
        if made.stdout.strip() != BYTECODE_TAG:
            raise ValueError('unexpected bytecode tag: ' + made.stdout.strip())
        for relative in sources:
            path = Path(relative)
            name = '{}.{}.pyc'.format(path.stem, BYTECODE_TAG)
            result[(path.parent / '__pycache__' / name).as_posix()] = (Path(folder) / path.parent / '__pycache__' / name).read_bytes()
    return result


# main.py, service.py and context.py are run as scripts, never imported: Python caches no bytecode for a script
compiled = bytecode({relative: data for relative, data in rewritten.items()
                     if relative.endswith('.py') and relative not in ('main.py', 'service.py', 'context.py')})

output_dir.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix='.' + addon_id + '-', suffix='.zip', dir=output_dir)
os.close(descriptor)
try:
    with zipfile.ZipFile(temporary, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
        for relative, path in files.items():
            member = addon_id + '/' + relative
            if relative in rewritten:
                info = zipfile.ZipInfo.from_file(path, member)
                info.compress_type = zipfile.ZIP_DEFLATED
                archive.writestr(info, rewritten[relative])
            else:
                archive.write(path, member)
        for relative, data in sorted(compiled.items()):
            archive.writestr(addon_id + '/' + relative, data)
    with zipfile.ZipFile(temporary) as archive:
        if archive.testzip() is not None:
            raise ValueError('ZIP integrity verification failed')
    os.replace(temporary, output)
finally:
    if os.path.exists(temporary):
        os.unlink(temporary)
print('Runtime preflight: {} files, {} bytecode files ({}), {} skin textures left to the system Estuary bundle.'.format(
    len(files), len(compiled), BYTECODE_TAG, len(native_names)), file=sys.stderr)
print(output)
PY
