#!/bin/sh
""":"
command -v python3 >/dev/null 2>&1 || { echo 'Install Python 3, then run this installer again.' >&2; exit 1; }
exec python3 -I "$0" "$@"
":"""
"""Studio Cucurbits Linux installer. The website embeds a verified release plan."""
import argparse
import base64
import getpass
import hashlib
import json
import os
import pathlib
import platform
import re
import shlex
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import urllib.request

CONFIG = json.loads(base64.b64decode('eyJ0aXRsZSI6IlN1c3BlbmRlZCIsInBhY2thZ2VzIjp7ImRlYiI6W3sibmFtZSI6InN0dWRpby1jdWN1cmJpdHMtY29tbW9uXzhfYW1kNjQuZGViIiwidXJsIjoiaHR0cHM6Ly9kb3dubG9hZHMuc3R1ZGlvY3VjdXJiaXRzLmNvbS9yZWxlYXNlcy9kZGYzYzE1MjRjNTU5YTk3N2U2ODRkNzcyMTZlMzQ1ZDM1ZDQ4MDBmMzVlY2MxNGM2MTEzOWQ1YjAzMDEyMjk1L2Fzc2V0cy9zdHVkaW8tY3VjdXJiaXRzLWNvbW1vbl84X2FtZDY0LmRlYiIsInNpemUiOjExOTY0OTYsInNoYTI1NiI6IjA5YjY4ZWE2ZGZhMWI4ZTk5YTI0ZTg3NjE0ZGY1N2RiZDU5MmE4OGI3ZjBhNDk4NDU2YTA2MWNkNzkxYzFkZDAiLCJkb3dubG9hZE5hbWUiOiJzdXNwZW5kZWQtMS4wLjAtbGludXgtY29tbW9uLXg2NC5kZWIiLCJmb3JtYXQiOiJkZWIiLCJza3VJZCI6ImNvbW1vbiIsInByb2R1Y3RzIjpbeyJpZCI6InN1c3BlbmRlZCIsInZlcnNpb24iOiIxLjAuMCJ9XSwic2lnbmF0dXJlIjp7InNpZ25hdHVyZSI6eyJhbGdvcml0aG0iOiJFZDI1NTE5Iiwia2V5SWQiOiJsaW51eC1wYWNrYWdlLTIwMjYtMDEiLCJ2YWx1ZSI6IjhHSzlobjRoeFkxX2ZPeXh0WWxLeEY3RXQ2aW55eGhiSWV4dHZ2eF9KS0g4VnlMb3UwNXpDTWJxT0pXbmxPcFZvSnNHSjBVanJYSmh4c3ItV1JZQUJRIn0sInNpZ25lZFBheWxvYWQiOnsiZG9tYWluIjoic3R1ZGlvLmN1Y3VyYml0cy5saW51eC1wYWNrYWdlLnYxIiwiZm9ybWF0IjoiZGViIiwicHJvZHVjdHMiOlt7ImlkIjoic3VzcGVuZGVkIiwidmVyc2lvbiI6IjEuMC4wIn1dLCJyZXZpc2lvbiI6OCwic2hhMjU2IjoiMDliNjhlYTZkZmExYjhlOTlhMjRlODc2MTRkZjU3ZGJkNTkyYTg4YjdmMGE0OTg0NTZhMDYxY2Q3OTFjMWRkMCIsInNpemUiOjExOTY0OTYsInNrdUlkIjoiY29tbW9uIn19LCJzaGFyZWRWZXJzaW9uIjoiOCJ9LHsibmFtZSI6InN0dWRpby1jdWN1cmJpdHMtc3VzcGVuZGVkXzhfYW1kNjQuZGViIiwidXJsIjoiaHR0cHM6Ly9kb3dubG9hZHMuc3R1ZGlvY3VjdXJiaXRzLmNvbS9yZWxlYXNlcy9kZGYzYzE1MjRjNTU5YTk3N2U2ODRkNzcyMTZlMzQ1ZDM1ZDQ4MDBmMzVlY2MxNGM2MTEzOWQ1YjAzMDEyMjk1L2Fzc2V0cy9zdHVkaW8tY3VjdXJiaXRzLXN1c3BlbmRlZF84X2FtZDY0LmRlYiIsIm9zIjoibGludXgiLCJhcmNoaXRlY3R1cmUiOiJ4ODZfNjQiLCJmb3JtYXQiOiJkZWIiLCJwbHVnaW5Gb3JtYXRzIjpbIlZTVDMiXSwibWluaW11bU9zVmVyc2lvbiI6bnVsbCwic2l6ZSI6NDY1MjE3Miwic2hhMjU2IjoiNzQ1MDU5NjExOTVhOGVhYjc5MTViMGNhOGVjMWEwYzIyNmFjNDMwYjAzNjhiZDRkMmZiN2FkYjk1N2QxZDA4MyIsImRvd25sb2FkTmFtZSI6InN1c3BlbmRlZC0xLjAuMC1saW51eC14NjQuZGViIiwic2t1SWQiOiJzdXNwZW5kZWQiLCJwcm9kdWN0cyI6W3siaWQiOiJzdXNwZW5kZWQiLCJ2ZXJzaW9uIjoiMS4wLjAifV0sInNpZ25hdHVyZSI6eyJzaWduYXR1cmUiOnsiYWxnb3JpdGhtIjoiRWQyNTUxOSIsImtleUlkIjoibGludXgtcGFja2FnZS0yMDI2LTAxIiwidmFsdWUiOiJmVGF1QTZpSWJJWnB4N1ZHa3VfQnRERmMtdDhCbm5ocDhuaEQyZ1gySW5vTlg4Q1Q1M1dUUGdpVmdzcUxyUm1LVF9JcDFtOGZvcTI3eDRJWFBydmFDdyJ9LCJzaWduZWRQYXlsb2FkIjp7ImRvbWFpbiI6InN0dWRpby5jdWN1cmJpdHMubGludXgtcGFja2FnZS52MSIsImZvcm1hdCI6ImRlYiIsInByb2R1Y3RzIjpbeyJpZCI6InN1c3BlbmRlZCIsInZlcnNpb24iOiIxLjAuMCJ9XSwicmV2aXNpb24iOjgsInNoYTI1NiI6Ijc0NTA1OTYxMTk1YThlYWI3OTE1YjBjYThlYzFhMGMyMjZhYzQzMGIwMzY4YmQ0ZDJmYjdhZGI5NTdkMWQwODMiLCJzaXplIjo0NjUyMTcyLCJza3VJZCI6InN1c3BlbmRlZCJ9fX1dLCJ0YXIuenN0IjpbeyJuYW1lIjoic3R1ZGlvLWN1Y3VyYml0cy1zdXNwZW5kZWQtMS04LnRhci56c3QiLCJ1cmwiOiJodHRwczovL2Rvd25sb2Fkcy5zdHVkaW9jdWN1cmJpdHMuY29tL3JlbGVhc2VzL2RkZjNjMTUyNGM1NTlhOTc3ZTY4NGQ3NzIxNmUzNDVkMzVkNDgwMGYzNWVjYzE0YzYxMTM5ZDViMDMwMTIyOTUvYXNzZXRzL3N0dWRpby1jdWN1cmJpdHMtc3VzcGVuZGVkLTEtOC50YXIuenN0Iiwib3MiOiJsaW51eCIsImFyY2hpdGVjdHVyZSI6Ing4Nl82NCIsImZvcm1hdCI6InRhci56c3QiLCJwbHVnaW5Gb3JtYXRzIjpbIlZTVDMiXSwibWluaW11bU9zVmVyc2lvbiI6bnVsbCwic2l6ZSI6NzU4NTE0NSwic2hhMjU2IjoiMTZmMjk4NmU5YTFiOGQ5YTllMjk1NzRiMzQwOGU5ZTYwZWUzMzIwOTllNjk5OGUxOTZmYjEyMGQxZmIxZDQ1ZiIsImRvd25sb2FkTmFtZSI6InN1c3BlbmRlZC0xLjAuMC1saW51eC14NjQudGFyLnpzdCIsInNrdUlkIjoic3VzcGVuZGVkIiwicHJvZHVjdHMiOlt7ImlkIjoic3VzcGVuZGVkIiwidmVyc2lvbiI6IjEuMC4wIn1dLCJzaWduYXR1cmUiOnsic2lnbmF0dXJlIjp7ImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlJZCI6ImxpbnV4LXBhY2thZ2UtMjAyNi0wMSIsInZhbHVlIjoiNWlnTjh1WmtGbDYzRTlIU3MtZjVPZWVhWEdmeEJta25HcXlGejZ2U2MtcEM4NHBMdnlVR0tYeDNzZVYyejZKdUlwV0Zwa0Nvc0FkdWVfb1UzSjBaRFEifSwic2lnbmVkUGF5bG9hZCI6eyJkb21haW4iOiJzdHVkaW8uY3VjdXJiaXRzLmxpbnV4LXBhY2thZ2UudjEiLCJmb3JtYXQiOiJ0YXIuenN0IiwicHJvZHVjdHMiOlt7ImlkIjoic3VzcGVuZGVkIiwidmVyc2lvbiI6IjEuMC4wIn1dLCJyZXZpc2lvbiI6MSwic2hhMjU2IjoiMTZmMjk4NmU5YTFiOGQ5YTllMjk1NzRiMzQwOGU5ZTYwZWUzMzIwOTllNjk5OGUxOTZmYjEyMGQxZmIxZDQ1ZiIsInNpemUiOjc1ODUxNDUsInNrdUlkIjoic3VzcGVuZGVkIn19fV19LCJ0cnVzdGVkS2V5cyI6eyJsaW51eC1wYWNrYWdlLTIwMjYtMDEiOiI1YzZjMDQ5OTQ1ZTA3MTY5ZjZiY2EyOTg4ZDcwNjY0MzU2OTYzYzZjYzBkNmZiNDhjNTFiMmNlMTQ3NzQ2NTIzIn0sInN0YWdpbmciOmZhbHNlfQ=='))


def package_format(distro):
    family = {distro.get('ID', ''), *distro.get('ID_LIKE', '').split()}
    return 'deb' if family & {'debian', 'ubuntu', 'linuxmint'} else 'tar.zst'


def distribution():
    for file in ['/etc/os-release', '/usr/lib/os-release']:
        if pathlib.Path(file).exists():
            result = {}
            for line in pathlib.Path(file).read_text().splitlines():
                if '=' in line and not line.startswith('#'):
                    key, value = line.split('=', 1)
                    words = shlex.split(value)
                    result[key] = words[0] if words else ''
            return result
    return {}


def shared_installed(version):
    result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'studio-cucurbits-common'], capture_output=True, text=True)
    return (result.returncode == 0 and result.stdout.strip() == 'install ok installed\t' + version
            and os.access('/opt/studio-cucurbits/bin/StudioCucurbitsUpdater', os.X_OK))


def verify_package(path, item, scratch):
    if path.is_symlink() or not path.is_file() or path.stat().st_size != item['size']:
        raise ValueError('Missing or incorrect package: ' + item['name'])
    digest = hashlib.sha256()
    with path.open('rb') as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b''):
            digest.update(chunk)
    if digest.hexdigest() != item['sha256']:
        raise ValueError('Package checksum failed: ' + item['name'])
    envelope = item['signature']
    signed, signature = envelope['signedPayload'], envelope['signature']
    if (signed.get('domain') != 'studio.cucurbits.linux-package.v1' or
            signed.get('sha256') != item['sha256'] or signed.get('size') != item['size'] or
            signed.get('format') != item['format'] or signed.get('skuId') != item['skuId'] or
            signed.get('products') != item['products'] or signature.get('algorithm') != 'Ed25519'):
        raise ValueError('Package signature identity differs: ' + item['name'])
    key = CONFIG['trustedKeys'].get(signature['keyId'])
    if key is None or not re.fullmatch('[a-f0-9]{64}', key):
        raise ValueError('Unknown package signing key')
    public, message, raw = (scratch / name for name in ['public.der', 'message.json', 'signature.bin'])
    public.write_bytes(bytes.fromhex('302a300506032b6570032100' + key))
    message.write_bytes((json.dumps(signed, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n').encode())
    raw.write_bytes(base64.urlsafe_b64decode(signature['value'] + '=='))
    result = subprocess.run(['openssl', 'pkeyutl', '-verify', '-rawin', '-pubin', '-keyform', 'DER', '-inkey', str(public), '-in', str(message), '-sigfile', str(raw)], capture_output=True)
    if result.returncode:
        raise ValueError('Package signature failed: ' + item['name'])


def verified_snapshot(source, item, scratch):
    destination = scratch / 'verified' / item['name']
    # Freeze offline inputs before verification. Package managers and zstd must
    # consume this private copy, even if the original changes during a sudo prompt.
    with os.fdopen(os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK), 'rb') as stream:
        if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
            raise ValueError('Package must be a regular file: ' + item['name'])
        with destination.open('xb') as output:
            size = 0
            while chunk := stream.read(1024 * 1024):
                size += len(chunk)
                if size > item['size']:
                    raise ValueError('Package exceeds expected size: ' + item['name'])
                output.write(chunk)
    destination.chmod(0o400)
    verify_package(destination, item, scratch)
    return destination


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ValueError('Installer download redirected; download again from the website')


def download(item, destination, opener, authorization):
    url = item['url']
    if not url.startswith('https://'):
        raise ValueError('Installer URL must use HTTPS')
    headers = {'User-Agent': 'StudioCucurbitsInstaller/1.0'}
    if authorization:
        headers['Authorization'] = authorization
    print('Downloading ' + item['name'], flush=True)
    request = urllib.request.Request(url, headers=headers)
    temporary = destination.with_name(destination.name + '.part')
    try:
        with opener.open(request, timeout=60) as response, temporary.open('xb') as output:
            size = 0
            while chunk := response.read(1024 * 1024):
                size += len(chunk)
                if size > item['size']:
                    raise ValueError('Download exceeds expected size')
                output.write(chunk)
        temporary.replace(destination)
    finally:
        temporary.unlink(missing_ok=True)


def install_debs(paths, offline):
    if not offline and shutil.which('apt-get'):
        command = ['apt-get', 'install', '--no-remove', '--reinstall', '--', *map(str, paths)]
    else:
        command = ['dpkg', '--install', *map(str, paths)]
    if os.geteuid() != 0:
        if not shutil.which('sudo'):
            raise ValueError('sudo is required for the system-wide Debian installation')
        command.insert(0, 'sudo')
    subprocess.run(command, check=True)


def unpack_generic(path, destination):
    # Inspect the signed archive before extraction; packages contain regular
    # files/directories only. Never allow an archive to escape its scratch root.
    tar_path = destination / 'package.tar'
    with tar_path.open('wb') as output:
        subprocess.run(['zstd', '--decompress', '--stdout', str(path)], stdout=output, check=True)
    with tarfile.open(tar_path) as archive:
        for member in archive.getmembers():
            parts = pathlib.PurePosixPath(member.name).parts
            if (not parts or parts[0] != 'studio-cucurbits-linux' or '..' in parts or
                    pathlib.PurePosixPath(member.name).is_absolute() or not (member.isfile() or member.isdir())):
                raise ValueError('Unsafe generic package path')
        if hasattr(tarfile, 'data_filter'):
            archive.extractall(destination, filter='data')
        else:
            archive.extractall(destination)
    return destination / 'studio-cucurbits-linux/install.sh'


def main(argv=None):
    parser = argparse.ArgumentParser(description='Install ' + CONFIG['title'] + ' and the shared Cucurbits updater. Close your audio hosts first.')
    parser.add_argument('--format', choices=['deb', 'generic'], help='Override distribution selection')
    group = parser.add_mutually_exclusive_group()
    group.add_argument('--download-only', type=pathlib.Path, metavar='DIRECTORY', help='Prepare packages for an offline machine; does not install')
    group.add_argument('--offline', type=pathlib.Path, metavar='DIRECTORY', help='Use already downloaded packages without network access')
    group.add_argument('--verify-only', type=pathlib.Path, metavar='DIRECTORY', help='Verify local packages without installing or accessing the network')
    args = parser.parse_args(argv)
    if platform.system() != 'Linux' or platform.machine().lower() not in ('x86_64', 'amd64'):
        raise ValueError('This release supports Linux x86_64 only')
    chosen = {'deb': 'deb', 'generic': 'tar.zst'}.get(args.format) or package_format(distribution())
    if not args.format and not CONFIG['packages'].get(chosen) and CONFIG['packages'].get('tar.zst'):
        print('This bundle requires the per-user generic installer because its DEB dependencies differ.', flush=True)
        chosen = 'tar.zst'
    groups = CONFIG['packages'].get(chosen)
    if not groups:
        raise ValueError('This release has no ' + chosen + ' installer; choose another format on the download page')
    for tool in ['openssl', *(['dpkg-query', 'dpkg-deb'] if chosen == 'deb' else ['zstd'])]:
        if not shutil.which(tool):
            raise ValueError('Install ' + tool + ' using your distribution package manager, then run this installer again')
    if chosen == 'deb' and not (args.download_only or args.verify_only):
        for item in groups:
            if item.get('sharedVersion'):
                continue
            record = pathlib.Path.home() / ('.local/opt/studio-cucurbits/registrations/studio-cucurbits-' + item['skuId'] + '.package.json')
            if record.exists():
                raise ValueError('Uninstall the existing per-user generic copy before installing the DEB version')
    if chosen == 'tar.zst' and not (args.download_only or args.verify_only) and os.geteuid() == 0:
        raise ValueError('Run the per-user generic installer as your normal user, without sudo')
    print('Installing ' + CONFIG['title'] + ' using ' + chosen + '.', flush=True)
    if CONFIG['staging']:
        print('Private staging release.', flush=True)
    # Download-only always includes the shared package, even when the online
    # computer already has it; the offline computer may have different state.
    selected = []
    for item in groups:
        if item.get('sharedVersion') and not (args.download_only or args.verify_only) and shared_installed(item['sharedVersion']):
            print('Shared Cucurbits updater is already installed (' + item['sharedVersion'] + ').', flush=True)
        else:
            selected.append(item)
    authorization = None
    if CONFIG['staging'] and not (args.offline or args.verify_only):
        username = input('Staging website username: ')
        password = getpass.getpass('Staging website password: ')
        authorization = 'Basic ' + base64.b64encode((username + ':' + password).encode()).decode()
    opener = urllib.request.build_opener(NoRedirect)
    with tempfile.TemporaryDirectory(prefix='studio-cucurbits-install-') as temporary:
        scratch = pathlib.Path(temporary)
        (scratch / 'verified').mkdir()
        folder = args.download_only or args.offline or args.verify_only or scratch
        if args.download_only:
            folder.mkdir(parents=True, exist_ok=True)
        folder = folder.resolve()
        paths = []
        for item in selected:
            destination = folder / item.get('downloadName', item['name'])
            if (args.offline or args.verify_only) and not destination.exists():
                destination = folder / item['name']
            if not (args.offline or args.verify_only):
                if not destination.exists():
                    download(item, destination, opener, authorization)
            paths.append(verified_snapshot(destination, item, scratch))
        if args.download_only:
            source = pathlib.Path(sys.argv[0]).resolve()
            if source != folder / 'install.sh':
                shutil.copyfile(source, folder / 'install.sh')
            print('Offline packages verified. On the target computer: sh install.sh --offline . --format ' + ('deb' if chosen == 'deb' else 'generic'))
            return
        if args.verify_only:
            print('All package checksums and signatures verified.')
            return
        if chosen == 'deb':
            required_versions = set()
            for path, item in zip(paths, selected):
                if not item.get('sharedVersion'):
                    dependencies = subprocess.check_output(['dpkg-deb', '-f', str(path), 'Depends'], text=True)
                    match = re.search(r'studio-cucurbits-common\s*\(=\s*([^ )]+)\)', dependencies)
                    if not match:
                        raise ValueError('Product package lacks the expected shared updater dependency')
                    required_versions.add(match[1])
            shared_versions = {i['sharedVersion'] for i in groups if i.get('sharedVersion')}
            if required_versions != shared_versions:
                raise ValueError('Shared updater revision does not match the product packages')
            install_debs(paths, offline=bool(args.offline))
        else:
            # Download and verify every bundle member before running any installer.
            launchers = []
            for index, path in enumerate(paths):
                extraction = scratch / str(index)
                extraction.mkdir()
                launchers.append(unpack_generic(path, extraction))
            for launcher in launchers:
                subprocess.run(['sh', str(launcher)], check=True)
            updater = pathlib.Path.home() / '.local/opt/studio-cucurbits/common/bin/StudioCucurbitsUpdater'
            if not updater.is_file() or not os.access(updater, os.X_OK):
                raise ValueError('The shared Cucurbits updater was not installed')
        print('Installation complete. Reopen your audio host and rescan its VST3 plug-ins.')


if __name__ == '__main__':
    try:
        main()
    except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
        sys.exit('Installation stopped: ' + str(error))
