#!/usr/bin/env python3
"""Prepare a local, hash-locked macOS ARM64 build workspace; no product services."""
import argparse
import copy
import hashlib
import importlib.util
import json
import os
from pathlib import Path, PurePosixPath
import platform
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time

BUNDLE = Path(__file__).resolve().parents[1]
MANIFEST = json.loads((BUNDLE / 'sources.json').read_text())
MANIFEST_HASH = hashlib.sha256((BUNDLE / 'sources.json').read_bytes()).hexdigest()


def digest(path):
    result = hashlib.sha256()
    with Path(path).open('rb') as stream:
        for data in iter(lambda: stream.read(1024 * 1024), b''):
            result.update(data)
    return result.hexdigest()


def verify(path, expected, expected_bytes=None):
    path = Path(path)
    if (path.is_symlink() or not path.is_file() or
            (expected_bytes is not None and path.stat().st_size != expected_bytes) or
            digest(path) != expected):
        raise SystemExit('Missing, nonregular or size/SHA256-mismatched input: ' + str(path))


def pruning_module():
    filename = BUNDLE / 'scripts/prune-emscripten.py'
    item = next(record for record in MANIFEST['sources'] if record['id'] == 'emscripten')
    derivation = item.get('derivation', {})
    if derivation.get('script') != 'scripts/prune-emscripten.py':
        raise SystemExit('Unexpected source-pruning script path')
    lock = derivation.get('scriptLock', {})
    verify(filename, lock['sha256'], lock['bytes'])
    spec = importlib.util.spec_from_file_location('audio_studio_source_pruning', filename)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def safe_members(archive, prefix):
    result = []
    for original in archive.getmembers():
        parts = PurePosixPath(original.name).parts
        if not parts or parts[0] != prefix or original.name.startswith('/') or '..' in parts:
            raise SystemExit('Unexpected archive path: ' + original.name)
        if len(parts) == 1:
            continue
        member = copy.copy(original)
        member.name = '/'.join(parts[1:])
        if member.issym():
            target = os.path.normpath(str(PurePosixPath(member.name).parent / member.linkname))
            if member.linkname.startswith('/') or target == '..' or target.startswith('../'):
                raise SystemExit('Archive symlink escapes its source directory: ' + member.name)
        elif member.islnk():
            link = PurePosixPath(member.linkname).parts
            if not link or link[0] != prefix or '..' in link:
                raise SystemExit('Unsafe archive hardlink: ' + member.name)
            member.linkname = '/'.join(link[1:])
        elif not (member.isfile() or member.isdir()):
            raise SystemExit('Unsupported special archive entry: ' + member.name)
        result.append(member)
    return result


def extract(archive_path, destination, prefix):
    destination.mkdir(parents=True, exist_ok=True)
    if any(destination.iterdir()):
        raise SystemExit('Refusing to overwrite a nonempty source directory: ' + str(destination))
    with tarfile.open(archive_path) as archive:
        archive.extractall(destination, members=safe_members(archive, prefix))


def self_check():
    for item in MANIFEST['sources']:
        path = BUNDLE / item['archive']
        verify(path, item['sha256'], item['bytes'])
        with tarfile.open(path) as archive:
            safe_members(archive, item['archiveRoot'])
        if item.get('derivation'):
            pruning_module().check_locked_derivation(item, BUNDLE)
    patch = json.loads((BUNDLE / 'patches/binding-patch.json').read_text())
    verify(BUNDLE / 'patches/patched-bind.js', patch['patchedSha256'])
    print(json.dumps({'sourceArchivesVerified': len(MANIFEST['sources']),
                      'bindingPatchVerified': True,
                      'sourceArchiveDerivationsVerified': sum(bool(item.get('derivation')) for item in MANIFEST['sources']),
                      'sourceDeliveryId': MANIFEST.get('sourceDeliveryId'),
                      'independentRepeatBuildVerified': False}))


def download(item, path):
    path = Path(path)
    if path.exists() or path.is_symlink():
        verify(path, item['sha256'], item.get('bytes'))
        if item.get('derivation'):
            pruning_module().check_locked_derivation(item, BUNDLE)
        return
    if item.get('derivation'):
        if item.get('id') != 'emscripten':
            raise SystemExit('Unexpected derived source input')
        module = pruning_module()
        derivation = item['derivation']
        if (derivation.get('policy') != module.POLICY or
                derivation.get('exclusions') != list(module.EXCLUSIONS)):
            raise SystemExit('Unexpected source-pruning policy')
        for key in ['script', 'receipt', 'inventory']:
            lock = derivation[key + 'Lock']
            verify(BUNDLE / derivation[key], lock['sha256'], lock['bytes'])
        upstream = item['upstream']
        archive_file = upstream['archiveFile']
        if Path(archive_file).name != archive_file or archive_file in ('.', '..'):
            raise SystemExit('Unsafe upstream cache filename')
        cache_directory = BUNDLE / '.upstream-cache'
        if cache_directory.is_symlink() or (cache_directory.exists() and not cache_directory.is_dir()):
            raise SystemExit('Upstream cache is not a plain directory')
        cache = cache_directory / archive_file
        if cache.exists() or cache.is_symlink():
            # An explicitly provided offline cache is read-only input. Never
            # fill this directory: BUNDLE may itself be a public asset tree.
            verify(cache, upstream['sha256'], upstream['bytes'])
            module.derive(cache, path, upstream, expected=item)
        else:
            # Full upstream recovery input is temporary and is removed on
            # success or failure, rather than becoming an anonymous download.
            with tempfile.TemporaryDirectory(prefix='audio-studio-emscripten-upstream-') as temporary:
                original = Path(temporary) / archive_file
                download(upstream, original)
                module.derive(original, path, upstream, expected=item)
        module.check_locked_derivation(item, BUNDLE)
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    partial = path.with_name(path.name + '.partial')
    subprocess.run(['curl', '--fail', '--location', '--silent', '--show-error',
                    '--retry', '2', '--retry-all-errors', '--connect-timeout', '15',
                    '--max-time', '1800', item['url'], '--output', str(partial)], check=True)
    verify(partial, item['sha256'], item.get('bytes'))
    partial.replace(path)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--work-dir', type=Path, default=BUNDLE / 'work')
    parser.add_argument('--self-check', action='store_true', help='Offline source archive and patch checks only')
    parser.add_argument('--prepare-only', action='store_true', help='Only verify and extract bundled source archives')
    parser.add_argument('--sdk-archive', type=Path, help='Use an existing SDK tarball, still verifying its locked SHA256')
    parser.add_argument('--node', type=Path, help='Use an existing native Node 22.23.1 executable with sibling npm')
    parser.add_argument('--jobs', type=int, default=8)
    args = parser.parse_args()
    if args.jobs < 1:
        parser.error('--jobs must be positive')
    if not args.self_check:
        for item in MANIFEST['sources']:
            if not (BUNDLE / item['archive']).exists():
                download(item, BUNDLE / item['archive'])
    self_check()
    if args.self_check:
        return
    if platform.system() != 'Darwin' or platform.machine() != 'arm64':
        raise SystemExit('The supplied compiler bootstrap is validated for macOS ARM64 only.')
    for executable in ['curl', 'make', 'cc']:
        if not shutil.which(executable):
            raise SystemExit('Missing host build tool: ' + executable + '; install Apple Command Line Tools separately.')
    work = args.work_dir.resolve()
    if any(character.isspace() for character in str(work)):
        raise SystemExit('Upstream configure/make needs a work directory without whitespace; use --work-dir.')
    marker = work / '.source-bootstrap-owner.json'
    if work.exists() and any(work.iterdir()) and not marker.exists():
        raise SystemExit('Choose an empty --work-dir; this script never clears unrelated files.')
    work.mkdir(parents=True, exist_ok=True)
    if marker.exists():
        state = json.loads(marker.read_text())
        if state.get('manifestSha256') != MANIFEST_HASH:
            raise SystemExit('Existing work directory belongs to different source inputs.')
    else:
        state = {'manifestSha256': MANIFEST_HASH, 'sourcesPrepared': []}
        marker.write_text(json.dumps(state, indent=2) + '\n')
    for item in MANIFEST['sources']:
        if item['id'] in state['sourcesPrepared']:
            continue
        extract(BUNDLE / item['archive'], work / 'source' / item['sourceDirectory'], item['archiveRoot'])
        state['sourcesPrepared'].append(item['id'])
        marker.write_text(json.dumps(state, indent=2) + '\n')
    if args.prepare_only:
        print('Bundled clean sources prepared at ' + str(work / 'source'))
        return
    if (work / 'toolchain-env.json').exists():
        print('Bootstrap already completed for these inputs. Use scripts/build-core.py to build.')
        return
    sdk = MANIFEST['toolchain']['sdk']
    sdk_archive = args.sdk_archive.resolve() if args.sdk_archive else work / 'downloads' / sdk['archiveFile']
    if args.sdk_archive:
        verify(sdk_archive, sdk['sha256'])
    else:
        print('Downloading the official LLVM/Binaryen SDK (339.7 MB).', flush=True)
        download(sdk, sdk_archive)
    if not (work / 'toolchain').exists():
        extract(sdk_archive, work / 'toolchain', sdk['archiveRoot'])
    node = MANIFEST['toolchain']['node']
    if args.node:
        node_binary = args.node.absolute()
    else:
        node_archive = work / 'downloads' / (node['archiveRoot'] + '.tar.gz')
        download(node, node_archive)
        if not (work / 'node').exists():
            extract(node_archive, work / 'node', node['archiveRoot'])
        node_binary = work / 'node/bin/node'
    node_version = subprocess.check_output([str(node_binary), '--version'], text=True).strip()
    node_arch = subprocess.check_output([str(node_binary), '-p', 'process.arch'], text=True).strip()
    if node_version != 'v' + node['version'] or node_arch != 'arm64':
        raise SystemExit('Node must be native ARM64 version ' + node['version'])
    npm = node_binary.parent / 'npm'
    if not npm.exists():
        raise SystemExit('The selected Node installation needs a sibling npm executable.')
    emscripten = work / 'source/emscripten'
    verify(emscripten / 'package-lock.json', MANIFEST['toolchain']['emscriptenPackageLockSha256'])
    for directory in ['toolchain-cache', 'toolchain-ports', 'tmp', 'bootstrap-logs', 'toolchain-smoke']:
        (work / directory).mkdir(exist_ok=True)
    if any((work / 'toolchain-cache').iterdir()):
        raise SystemExit('Choose a fresh work directory: bootstrap requires a new runtime cache, never SDK precompiled libraries.')
    config = {'LLVM_ROOT': str(work / 'toolchain/bin'), 'BINARYEN_ROOT': str(work / 'toolchain'),
              'NODE_JS': str(node_binary), 'CACHE': str(work / 'toolchain-cache'),
              'PORTS': str(work / 'toolchain-ports'), 'FROZEN_CACHE': False, 'JAVA': 'java'}
    (work / 'emscripten-config.py').write_text(''.join(f'{key} = {value!r}\n' for key, value in config.items()))
    environment = {'PATH': ':'.join([str(emscripten), str(work / 'host-tools/bin'),
                                    str(work / 'toolchain/bin'), str(node_binary.parent),
                                    '/usr/bin', '/bin', '/usr/sbin', '/sbin']),
                   'EM_CONFIG': str(work / 'emscripten-config.py'), 'EM_CACHE': str(work / 'toolchain-cache'),
                   'EM_PORTS': str(work / 'toolchain-ports'), 'EM_LLVM_ROOT': str(work / 'toolchain/bin'),
                   'EM_BINARYEN_ROOT': str(work / 'toolchain'), 'EM_NODE_JS': str(node_binary),
                   'EM_FROZEN_CACHE': '0', 'EMCC_USE_NINJA': '0', 'EMCC_CORES': str(args.jobs),
                   'EMSDK_PYTHON': sys.executable, 'TMPDIR': str(work / 'tmp')}
    env = dict(os.environ)
    env.update(environment)
    commands = []

    def run(label, command, cwd, command_env=env):
        print('Bootstrap: ' + label, flush=True)
        start = time.monotonic()
        log = work / 'bootstrap-logs' / (label + '.log')
        with log.open('w') as stream:
            result = subprocess.run(command, cwd=cwd, env=command_env, stdout=stream, stderr=subprocess.STDOUT)
        commands.append({'step': label, 'command': command, 'exitCode': result.returncode,
                         'seconds': round(time.monotonic() - start, 3), 'log': str(log.relative_to(work))})
        (work / 'bootstrap-commands.json').write_text(json.dumps(commands, indent=2) + '\n')
        if result.returncode:
            raise SystemExit('Bootstrap failed: ' + label + '; see ' + str(log))
        return log.read_text()

    # No account configuration is needed. npm reads only empty workspace configuration files.
    for name in ['npm-user.npmrc', 'npm-global.npmrc']:
        (work / name).write_text('')
    run('npm-ci', [str(npm), 'ci', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund',
                   '--cache', str(work / 'npm-cache'), '--userconfig', str(work / 'npm-user.npmrc'),
                   '--globalconfig', str(work / 'npm-global.npmrc'), '--registry=https://registry.npmjs.org'], emscripten)
    host_env = dict(os.environ)
    for name in ['CC', 'CXX', 'AR', 'RANLIB', 'CFLAGS', 'CXXFLAGS', 'LDFLAGS', 'CPPFLAGS']:
        host_env.pop(name, None)
    host_env['CC'] = '/usr/bin/clang'
    host_env['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin'
    host_env['TMPDIR'] = str(work / 'tmp')
    pkgconf = work / 'source/pkgconf'
    run('pkgconf-configure', ['./configure', '--prefix=' + str(work / 'host-tools'),
                             '--disable-shared', '--enable-static'], pkgconf, host_env)
    run('pkgconf-build', ['make', '-j' + str(args.jobs)], pkgconf, host_env)
    run('pkgconf-install', ['make', 'install'], pkgconf, host_env)
    version = run('emcc-version', [str(emscripten / 'emcc'), '--version'], work)
    clang_version = run('clang-version', [str(work / 'toolchain/bin/clang'), '--version'], work)
    binaryen_version = run('binaryen-version', [str(work / 'toolchain/bin/wasm-opt'), '--version'], work)
    smoke = work / 'toolchain-smoke'
    (smoke / 'hello.c').write_text('#include <stdio.h>\nint main(void) { puts("fixed-source-smoke-ok"); return 0; }\n')
    run('smoke-build', [str(emscripten / 'emcc'), str(smoke / 'hello.c'), '-O3', '-msimd128',
                        '-sSINGLE_FILE=1', '-sENVIRONMENT=node', '-sEXIT_RUNTIME=1',
                        '-o', str(smoke / 'hello.cjs')], work)
    result = run('smoke-run', [str(node_binary), str(smoke / 'hello.cjs')], work)
    if 'fixed-source-smoke-ok' not in result:
        raise SystemExit('Unexpected toolchain smoke output.')
    receipt = {'environment': environment, 'bootstrapPassed': True, 'smokePassed': True,
               'sourcesManifestSha256': MANIFEST_HASH, 'sdkSha256': sdk['sha256'],
               'nodeVersion': node_version, 'nodeSha256': digest(node_binary),
               'emccVersion': version.strip(), 'clangVersion': clang_version.strip(),
               'binaryenVersion': binaryen_version.strip(), 'runtimeCacheInitiallyEmpty': True,
               'sdkPrecompiledRuntimeCacheUsed': False, 'frozenCache': False,
               'independentRepeatBuildVerified': False}
    (work / 'toolchain-env.json').write_text(json.dumps(receipt, indent=2) + '\n')
    print('Bootstrap succeeded. Next: python3 scripts/build-core.py --work-dir ' + str(work))


if __name__ == '__main__':
    main()
