#!/usr/bin/env python3
"""Build and publish the Kodi repository (addon ZIP + repository addon + addons.xml).

  release.py build      build + verify existing versions, upload nothing
  release.py publish --dry-run  prepare and verify a complete release in an isolated copy
  release.py publish --snapshot-defaults  snapshot maintainer settings before release
  release.py publish    (--no-prune: leave replaced ZIPs where they are) raise the version above the published one (--no-bump: refuse instead), build, upload to the hosting, switch atomically, verify public URLs,
                        deploy the whole development tree, prune old versions, record deploy

Layout published under https://hosting.pilsfree.net/chudy/kodi/ :
  repo/addons.xml, repo/addons.xml.md5            what Kodi polls (switched last)
  repo/plugin.video.milionar/<id>-<ver>.zip       + icon at its metadata path
  repo/repository.milionar/<id>-<ver>.zip         + icon.png
  plugin.video.milionar-latest.zip                direct "install from zip" link
  plugin.video.milionar-<ver>.zip                 versioned copy
  repository.milionar-latest.zip                  one-time install of the repository
  dev/current/                                    the whole development tree of this release (unpacked)
  dev/previous/                                   the tree of the release before it (older ones are deleted: they are in git)
  old/                                            every replaced versioned ZIP is MOVED here (repo/ ones to old/repo/); never deleted
Retention: repo/ and the hosting root hold only the CURRENT versioned ZIP (+ the -latest links); dev/ holds current + previous.
Everything is uploaded to a temporary directory, SHA256-checked on the server and only then renamed into place.
"""
import argparse
import hashlib
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
import xml.etree.ElementTree as ET
import zipfile

ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
HOST = 'chudy@hosting.pilsfree.net'
REMOTE = 'public_html/kodi'
PUBLIC = 'https://hosting.pilsfree.net/chudy/kodi'
ADDON = 'plugin.video.milionar'
REPO_ADDON = 'repository.milionar'
REPO_DIR = os.path.join(ROOT, 'repository', REPO_ADDON)
LOGO = 'resources/media/brand/milionar-mark.png'
OLD = 'old'  # hosting folder that receives the replaced ZIPs; the script only ever ADDS to it (mv -n), never deletes or overwrites there
# dev/ is a 100% 1:1 backup of the workspace: NOTHING is excluded (.git, docs, .agents, wcs/devtools, scripts, caches,
# harness outputs, symlinks, empty folders - all of it, with permissions); the addon folder IS the development workspace
DEV_EXCLUDES = ()
SECRET = re.compile(r'(?<![A-Za-z0-9_-])(?:sk[-_][A-Za-z0-9_-]{20,300}|AIza[0-9A-Za-z_-]{30,}|gsk_[A-Za-z0-9]{20,})(?![A-Za-z0-9_-])')


def run(*command, **kwargs):
    return subprocess.run(command, check=True, text=True, **kwargs)


def sha256(path):
    digest = hashlib.sha256()
    with open(path, 'rb') as handle:
        for block in iter(lambda: handle.read(1 << 20), b''):
            digest.update(block)
    return digest.hexdigest()


def build_repository_zip(output, version):
    with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive:
        for name in sorted(os.listdir(REPO_DIR)):
            archive.write(os.path.join(REPO_DIR, name), '{}/{}'.format(REPO_ADDON, name))


def addon_xml(archive_path, member):
    with zipfile.ZipFile(archive_path) as archive:
        data = archive.read(member).decode('utf-8')
    return re.sub(r'^\s*<\?xml[^>]*\?>\s*', '', data).strip()


def build(stage):
    run(sys.executable, os.path.join(ROOT, 'scripts/skin_harness/deploy_check.py'), 'check')
    plugin_version = ET.parse(os.path.join(ROOT, 'addon.xml')).getroot().get('version')
    repo_version = ET.parse(os.path.join(REPO_DIR, 'addon.xml')).getroot().get('version')
    built = os.path.join(stage, 'built')
    os.makedirs(built)
    output = run('sh', os.path.join(ROOT, 'scripts/build_zip.sh'), built, stdout=subprocess.PIPE).stdout.strip()
    plugin_zip = os.path.join(built, '{}-{}.zip'.format(ADDON, plugin_version))
    if os.path.abspath(output) != plugin_zip:
        raise SystemExit('unexpected build output: ' + output)
    run('unzip', '-tq', plugin_zip, stdout=subprocess.DEVNULL)
    with zipfile.ZipFile(plugin_zip) as archive:
        names = archive.namelist()
        if any(not name.startswith(ADDON + '/') for name in names):
            raise SystemExit('ZIP must contain the single top-level folder ' + ADDON)
        if any(name.startswith(ADDON + '/' + prefix) for name in names
               for prefix in ('.agents', '.claude', 'docs/', 'scripts/', 'repository/', 'AGENTS.md')):
            raise SystemExit('development files leaked into the release ZIP')
    # Exercise shipped playback code and the service after release-only stripping, before any upload.
    run(sys.executable, '-m', 'unittest', 'tests.test_playback_language', '-v', cwd=ROOT,
        env=dict(os.environ, MILIONAR_TEST_RELEASE_ZIP=plugin_zip))
    repo_zip = os.path.join(built, '{}-{}.zip'.format(REPO_ADDON, repo_version))
    build_repository_zip(repo_zip, repo_version)
    run('unzip', '-tq', repo_zip, stdout=subprocess.DEVNULL)

    repo = os.path.join(stage, 'repo')
    plugin_dir, repo_addon_dir = (os.path.join(repo, ADDON), os.path.join(repo, REPO_ADDON))
    os.makedirs(os.path.join(plugin_dir, os.path.dirname(LOGO)))
    os.makedirs(repo_addon_dir)
    shutil.copy(plugin_zip, plugin_dir)
    shutil.copy(repo_zip, repo_addon_dir)
    with zipfile.ZipFile(plugin_zip) as archive:
        with open(os.path.join(plugin_dir, LOGO), 'wb') as handle:
            handle.write(archive.read('{}/{}'.format(ADDON, LOGO)))
    shutil.copy(os.path.join(REPO_DIR, 'icon.png'), repo_addon_dir)
    addons = '<?xml version="1.0" encoding="UTF-8"?>\n<addons>\n{}\n{}\n</addons>\n'.format(
        addon_xml(plugin_zip, '{}/addon.xml'.format(ADDON)), addon_xml(repo_zip, '{}/addon.xml'.format(REPO_ADDON)))
    ET.fromstring(addons)  # must be well-formed
    with open(os.path.join(repo, 'addons.xml'), 'w', encoding='utf-8') as handle:
        handle.write(addons)
    with open(os.path.join(repo, 'addons.xml.md5'), 'w') as handle:
        handle.write(hashlib.md5(addons.encode('utf-8')).hexdigest())
    for source, name in ((plugin_zip, '{}-latest.zip'.format(ADDON)), (repo_zip, '{}-latest.zip'.format(REPO_ADDON)),
                         (plugin_zip, os.path.basename(plugin_zip))):
        shutil.copy(source, os.path.join(stage, name))
        os.chmod(os.path.join(stage, name), 0o644)
    for folder, _, files in os.walk(repo):
        for name in files:
            os.chmod(os.path.join(folder, name), 0o644)
    print('built {} {} + {} {} ({} files in the ZIP)'.format(ADDON, plugin_version, REPO_ADDON, repo_version, len(names)))
    return plugin_version


def remote(command):
    return run('ssh', '-o', 'BatchMode=yes', HOST, command, stdout=subprocess.PIPE).stdout


def publish(stage, version):
    tmp = '{}/.upload-{}-{}'.format(REMOTE, version, os.getpid())
    remote('rm -rf {0} && mkdir -p {0}'.format(tmp))
    try:
        run('rsync', '-rt', '-e', 'ssh -o BatchMode=yes', stage + '/', '{}:{}/'.format(HOST, tmp),
            '--exclude=built')
        local = {}
        for folder, _, files in os.walk(stage):
            if os.path.relpath(folder, stage).startswith('built'):
                continue
            for name in files:
                path = os.path.join(folder, name)
                local[os.path.relpath(path, stage)] = sha256(path)
        listing = remote('cd {} && find . -type f -exec sha256sum {{}} +'.format(tmp))
        remote_hashes = {line.split(None, 1)[1].strip()[2:]: line.split()[0] for line in listing.splitlines()}
        if remote_hashes != local:
            raise SystemExit('upload verification failed (SHA256 mismatch on the server)')
        plugin_rel = 'repo/{0}/{0}-{1}.zip'.format(ADDON, version)
        order = [
            plugin_rel, 'repo/{0}/{1}'.format(ADDON, LOGO),
            'repo/{0}/{0}-1.0.0.zip'.format(REPO_ADDON), 'repo/{}/icon.png'.format(REPO_ADDON),
            '{}-{}.zip'.format(ADDON, version), '{}-latest.zip'.format(ADDON), '{}-latest.zip'.format(REPO_ADDON),
            'repo/addons.xml', 'repo/addons.xml.md5',  # the index flips last: it never points to a missing ZIP
        ]
        repo_zip = [name for name in local if name.startswith('repo/{}/'.format(REPO_ADDON)) and name.endswith('.zip')][0]
        order[2] = repo_zip
        commands = ['cd {}'.format(REMOTE)]
        for name in order:
            commands.append('mkdir -p {}'.format(os.path.dirname(name) or '.'))
            commands.append('mv -f {}/{} {}'.format(os.path.basename(tmp), name, name))  # relative to REMOTE
        remote(' && '.join(commands))
    finally:
        remote('rm -rf {}'.format(tmp))
    for name in ('repo/addons.xml', 'repo/addons.xml.md5', plugin_rel, '{}-latest.zip'.format(ADDON),
                 '{}-latest.zip'.format(REPO_ADDON)):
        with urllib.request.urlopen('{}/{}'.format(PUBLIC, name), timeout=60) as response:
            body = response.read()
        if response.status != 200 or hashlib.sha256(body).hexdigest() != local[name]:
            raise SystemExit('public verification failed: ' + name)
        print('OK  {}/{}'.format(PUBLIC, name))


def dev_files():
    """Relative paths of the whole workspace that go to dev/ (everything; symlinks to folders are listed as links,
    empty folders as folders)."""
    found = []
    for folder, dirs, files in os.walk(ROOT):
        rel_folder = os.path.relpath(folder, ROOT)
        dirs[:] = sorted(d for d in dirs if not excluded(os.path.normpath(os.path.join(rel_folder, d))))
        found.extend(os.path.normpath(os.path.join(rel_folder, d)) for d in dirs
                     if os.path.islink(os.path.join(folder, d)))
        if rel_folder != '.' and not dirs and not files and not os.path.islink(folder):
            found.append(os.path.normpath(rel_folder))
        for name in sorted(files):
            rel = os.path.normpath(os.path.join(rel_folder, name))
            if not excluded(rel):
                found.append(rel)
    return found


def excluded(rel):
    import fnmatch
    parts = rel.split(os.sep)
    for pattern in DEV_EXCLUDES:
        if '/' in pattern:
            if rel == pattern or rel.startswith(pattern + '/'):
                return True
        elif any(fnmatch.fnmatch(part, pattern) for part in parts):
            return True
    return False


def scan_secrets(files):
    """Nothing that looks like an API key may leave this machine."""
    hits = []
    for rel in files:
        path = os.path.join(ROOT, rel)
        if os.path.islink(path) or os.path.getsize(path) > 5 << 20:
            continue
        try:
            with open(path, encoding='utf-8') as handle:
                text = handle.read()
        except (UnicodeDecodeError, OSError):
            continue
        if SECRET.search(text):
            hits.append(rel)
    if hits:
        raise SystemExit('possible secret in: ' + ', '.join(hits))


def version_key(name, prefix):
    match = re.fullmatch(re.escape(prefix) + r'-(\d+(?:\.\d+)*)\.zip', name)
    return tuple(int(part) for part in match.group(1).split('.')) if match else None


def prune():
    """Move every versioned ZIP except the newest one to old/ (repo/ ones to old/repo/). Nothing is deleted, nothing in old/ is overwritten."""
    moved = []
    remote('mkdir -p {0}/{1}/repo'.format(REMOTE, OLD))
    for folder, prefix, target in (('repo/' + ADDON, ADDON, OLD + '/repo'), ('repo/' + REPO_ADDON, REPO_ADDON, OLD + '/repo'),
                                   ('.', ADDON, OLD), ('.', REPO_ADDON, OLD)):
        names = remote('cd {} && ls -1'.format(os.path.join(REMOTE, folder))).split()
        versions = sorted(((version_key(name, prefix), name) for name in names if version_key(name, prefix)), reverse=True)
        for _, name in versions[1:]:
            remote('cd {} && mv -n {} {}/{}'.format(REMOTE, os.path.join(folder, name), target, name))
            if remote('cd {} && {{ [ -e {} ] && echo stays || true; }}'.format(REMOTE, os.path.join(folder, name))).strip():
                print('WARNING: {}/{} stays: {}/{} already exists'.format(folder, name, target, name))
            else:
                moved.append('{}/{}'.format(target, name))
                print('moved {}/{} -> {}/{}'.format(folder, name, target, name))
    return moved


def publish_dev(version):
    """Upload the whole development tree to dev/current; the one of the release before moves to dev/previous (older ones go)."""
    files = dev_files()
    scan_secrets(files)
    tmp = '{}/dev/.upload-{}-{}'.format(REMOTE, version, os.getpid())
    remote('rm -rf {0} && mkdir -p {0}'.format(tmp))
    try:
        manifest = {'version': version, 'deployed': time.strftime('%Y-%m-%d %H:%M:%S %z'), 'files': len(files), 'complete_backup': True,
                    'excludes': list(DEV_EXCLUDES)}
        local = {rel: sha256(os.path.join(ROOT, rel)) for rel in files
                 if os.path.isfile(os.path.join(ROOT, rel)) and not os.path.islink(os.path.join(ROOT, rel))}
        listing = tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False)
        listing.write('\n'.join(files) + '\n')
        listing.close()
        try:
            run('rsync', '-rtlp', '-e', 'ssh -o BatchMode=yes', '--files-from=' + listing.name,
                ROOT + '/', '{}:{}/'.format(HOST, tmp))
        finally:
            os.unlink(listing.name)
        # modes stay as in the workspace (executable bits too); only read access for the web server is added
        remote("find {0} -type d -exec chmod u+rwx,go+rx {{}} + && find {0} -type f -exec chmod u+rw,go+r {{}} +".format(tmp))
        out = remote('cd {} && find . -type f -exec sha256sum {{}} +'.format(tmp))
        remote_hashes = {line.split(None, 1)[1].strip()[2:]: line.split()[0] for line in out.splitlines()}   # find -type f: links are not files
        if remote_hashes != local:
            raise SystemExit('dev upload verification failed (SHA256 mismatch on the server)')
        links = sorted(rel for rel in files if os.path.islink(os.path.join(ROOT, rel)))
        empty = sorted(rel for rel in files if os.path.isdir(os.path.join(ROOT, rel)) and not os.path.islink(os.path.join(ROOT, rel)))
        remote_links = sorted(line[2:] for line in remote('cd {} && find . -type l'.format(tmp)).splitlines() if line.strip())
        remote_empty = sorted(line[2:] for line in remote('cd {} && find . -mindepth 1 -type d -empty'.format(tmp)).splitlines() if line.strip())
        if remote_links != links or remote_empty != empty:
            raise SystemExit('dev upload verification failed (symlinks or empty folders differ on the server)')
        remote("cat > {}/DEPLOY.json <<'EOF'\n{}\nEOF".format(tmp, json.dumps(manifest, indent=1)))
        remote('cd {0}/dev && rm -rf previous && {{ [ ! -d current ] || mv current previous; }} && mv {1} current'.format(
            REMOTE, os.path.basename(tmp)))  # relative to dev/
    finally:
        remote('rm -rf {}'.format(tmp))
    with urllib.request.urlopen('{}/dev/current/DEPLOY.json'.format(PUBLIC), timeout=60) as response:
        if response.status != 200 or json.loads(response.read())['version'] != version:
            raise SystemExit('public verification failed: dev/current/DEPLOY.json')
    print('OK  {}/dev/current/ ({} files)'.format(PUBLIC, len(files)))


def ensure_new_version(bump=True):
    """Every release gets a version Kodi will offer as an update: higher than the published one.

    If addon.xml is not above the published version it is raised to published + 0.0.1 (patch). Returns the version."""
    path = os.path.join(ROOT, 'addon.xml')
    with open(path, encoding='utf-8') as handle:
        text = handle.read()
    match = re.search(r'(<addon\b[^>]*?\bversion=")(\d+(?:\.\d+)*)(")', text)
    local = tuple(int(p) for p in match.group(2).split('.'))
    with urllib.request.urlopen(PUBLIC + '/repo/addons.xml', timeout=60) as response:
        published = [node.get('version') for node in ET.fromstring(response.read()).iter('addon') if node.get('id') == ADDON]
    if not published:
        return match.group(2)
    old = tuple(int(p) for p in published[0].split('.'))
    if local > old:
        return match.group(2)
    if not bump:
        raise SystemExit('version {} is not above the published {} (run without --no-bump)'.format(match.group(2), published[0]))
    new = '.'.join(str(p) for p in old[:-1] + (old[-1] + 1,))
    with open(path, 'w', encoding='utf-8') as handle:
        handle.write(text[:match.start(2)] + new + text[match.end(2):])
    print('version {} -> {} (published: {})'.format(match.group(2), new, published[0]))
    return new


def write_news():
    """The newest entry of the skin changelog as the addon's <news>: Kodi shows it with the add-on and the update window
    (wcs/skin/update.py) reads it from the repository index. A release without its own entry has no news."""
    from xml.sax.saxutils import escape
    with open(os.path.join(ROOT, 'resources/skin.milionar/changelog.txt'), encoding='utf-8') as handle:
        entry = handle.read().split('\n\n', 1)[0].splitlines()[1:]
    lines = [line.lstrip('• ').strip() for line in entry if line.strip() and 'Release with Milionář' not in line]
    path = os.path.join(ROOT, 'addon.xml')
    with open(path, encoding='utf-8') as handle:
        text = handle.read()
    metadata = '<extension point="xbmc.addon.metadata">'
    if metadata not in text:
        return
    text = re.sub(r'\n?[ \t]*<news>.*?</news>', '', text, flags=re.S)
    if lines:
        text = text.replace(metadata, metadata + '\n        <news>{}</news>'.format(escape(' '.join(lines))[:1500]), 1)
    with open(path, 'w', encoding='utf-8') as handle:
        handle.write(text)


def prepare(snapshot=False, bump=True):
    version = ensure_new_version(bump)
    if snapshot:
        run(sys.executable, os.path.join(ROOT, 'scripts/snapshot_defaults.py'))
    spec = importlib.util.spec_from_file_location('deploy_check', os.path.join(ROOT, 'scripts/skin_harness/deploy_check.py'))
    guard = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(guard)
    guard.prepare(version)
    write_news()
    scan_secrets(dev_files())


def report(stage, version, mode, moved):
    path = os.path.join(stage, '{}-latest.zip'.format(ADDON))
    with zipfile.ZipFile(path) as archive:
        count = len(archive.namelist())
    result = {'mode': mode, 'version': version,
              'skin_version': ET.parse(os.path.join(ROOT, 'resources/skin.milionar/addon.xml')).getroot().get('version'),
              'zip_bytes': os.path.getsize(path), 'zip_files': count, 'zip_sha256': sha256(path),
              'urls': {'addon': PUBLIC + '/' + ADDON + '-latest.zip',
                       'repository': PUBLIC + '/' + REPO_ADDON + '-latest.zip',
                       'index': PUBLIC + '/repo/addons.xml', 'sources': PUBLIC + '/dev/current/'},
              'archived': moved}
    target = os.path.join(ROOT, '.agents/state/release.json') if mode == 'published' else os.path.join(stage, 'release.json')
    os.makedirs(os.path.dirname(target), exist_ok=True)
    with open(target, 'w', encoding='utf-8') as handle:
        json.dump(result, handle, ensure_ascii=False, indent=1)
        handle.write('\n')
    print('RESULT ' + json.dumps(result, ensure_ascii=False, sort_keys=True))
    print('report: ' + target)


def main():
    global ROOT, REPO_DIR
    parser = argparse.ArgumentParser(description='Build or deploy the complete Kodi repository with one command.')
    parser.add_argument('mode', choices=('build', 'publish'), nargs='?', default='build')
    parser.add_argument('--dry-run', action='store_true', help='prepare and verify a release in an isolated copy; upload nothing')
    parser.add_argument('--snapshot-defaults', action='store_true', help='include the maintainer Kodi settings as shipped defaults')
    parser.add_argument('--no-bump', action='store_true', help='refuse if the plugin version is not above the published version')
    parser.add_argument('--no-prune', action='store_true', help='leave replaced ZIPs where they are')
    args = parser.parse_args()
    if args.mode != 'publish' and any((args.dry_run, args.snapshot_defaults, args.no_bump, args.no_prune)):
        parser.error('release options require publish')
    original_root, original_repo = ROOT, REPO_DIR
    stage = tempfile.mkdtemp(prefix='milionar-release-')
    try:
        if args.dry_run:
            candidate = os.path.join(stage, 'source')
            for name in dev_files():
                target = os.path.join(candidate, name)
                source = os.path.join(ROOT, name)
                if os.path.isdir(source) and not os.path.islink(source):
                    os.makedirs(target, exist_ok=True)       # an empty folder of the workspace
                    continue
                os.makedirs(os.path.dirname(target), exist_ok=True)
                shutil.copy2(source, target, follow_symlinks=False)
            ROOT, REPO_DIR = candidate, os.path.join(candidate, 'repository', REPO_ADDON)
        if args.mode == 'publish':
            if not args.dry_run:
                remote('true')  # fail before changing local versions if SSH is unavailable
            prepare(snapshot=args.snapshot_defaults, bump=not args.no_bump)
        version = build(stage)
        if args.mode == 'publish' and not args.dry_run:
            publish(stage, version)
            run(sys.executable, os.path.join(ROOT, 'scripts/skin_harness/deploy_check.py'), 'record')
            publish_dev(version)
            moved = prune() if not args.no_prune else []
            report(stage, version, 'published', moved)
            print('published {} -> {}/{}-latest.zip'.format(version, PUBLIC, ADDON))
        else:
            report(stage, version, 'dry-run' if args.dry_run else 'built', [])
            print('stage kept at ' + stage)
    finally:
        ROOT, REPO_DIR = original_root, original_repo
        if args.mode == 'publish' and not args.dry_run:
            shutil.rmtree(stage, ignore_errors=True)


if __name__ == '__main__':
    main()
