#!/usr/bin/env python3
"""Build the pinned single-thread core without the x265 library.

Requires the unchanged Emscripten 3.1.40 SDK and native build utilities.
Per-library install trees mirror the isolation in Dockerfile.upstream.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tarfile
import urllib.request

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--work-dir', required=True)
parser.add_argument('--emsdk', required=True)
parser.add_argument('--jobs', type=int, default=4)
parser.add_argument('--resume', action='store_true', help='Reuse completed local stages for the same pinned inputs')
args = parser.parse_args()
repo = Path(__file__).resolve().parents[2]
work = Path(args.work_dir).resolve()
sdk = Path(args.emsdk).resolve()
record = repo / 'third_party/ffmpeg-build'
work.mkdir(parents=True, exist_ok=True)
for name in ['src', 'install', 'logs', 'build-scripts', 'completed']:
    (work / name).mkdir(exist_ok=True)
env_raw = subprocess.check_output(['bash', '-c', 'source "$1/emsdk_env.sh" >/dev/null 2>&1; env -0', 'bash', str(sdk)])
base = dict(item.decode().split('=', 1) for item in env_raw.split(b'\0') if item)
base['PATH'] = str(work / 'tools/usr/bin') + ':' + base['PATH']
base['ACLOCAL_PATH'] = str(work / 'tools/usr/share/aclocal') + ':' + base.get('ACLOCAL_PATH', '')
base.update(FFMPEG_ST='yes', FFMPEG_MT='', EMCC_CORES=str(args.jobs))
version = subprocess.check_output(['emcc', '--version'], env=base, text=True)
if not re.search(r'\b3\.1\.40\b', version):
    raise SystemExit('Only the reviewed Emscripten 3.1.40 toolchain is accepted')
(work / 'logs/toolchain.txt').write_text(version)
sources = {s['name']: s for s in json.loads((record / 'sources.json').read_text()) if s['name'] != 'x265'}
# x265 is never fetched, extracted, built or linked by this pipeline.
for source in sources.values():
    name = source['name']
    archive = record / 'downloads' / f"{name}-{source['commit']}.tar.gz"
    if not archive.exists():
        archive.parent.mkdir(parents=True, exist_ok=True)
        urllib.request.urlretrieve(source['url'], archive)
    data = archive.read_bytes()
    if hashlib.sha256(data).hexdigest() != source['sha256'] or len(data) != source['bytes']:
        raise SystemExit(f'Unreviewed source archive: {name}')
    # Emscripten is supplied by the pinned SDK. Its SDL port validates the pinned
    # release ZIP independently; retain these archives for source distribution.
    if name in ('emscripten', 'SDL'):
        continue
    target = work / 'src' / name
    # Resume only in an explicitly chosen work directory; source hashes are still checked.
    if args.resume and (target / '.formatplease-source.sha256').exists() and (target / '.formatplease-source.sha256').read_text() == source['sha256']:
        continue
    if target.exists():
        shutil.rmtree(target)
    target.mkdir()
    with tarfile.open(archive) as tar:
        for member in tar.getmembers():
            relative = member.name.split('/', 1)
            if len(relative) < 2 or not relative[1]:
                continue
            member.name = relative[1]
            if member.issym():
                # Some patched Python 3.10 data filters incorrectly resolve relative
                # symlinks from the archive root. Validate the actual link location.
                location = target / member.name
                resolved = (location.parent / member.linkname).resolve()
                if not resolved.is_relative_to(target) or not location.parent.resolve().is_relative_to(target):
                    raise SystemExit(f'Unsafe archive symlink: {member.name}')
                location.parent.mkdir(parents=True, exist_ok=True)
                location.symlink_to(member.linkname)
            else:
                tar.extract(member, target, filter='data')
    (target / '.formatplease-source.sha256').write_text(source['sha256'])

upstream = work / 'src/ffmpeg.wasm'
for script in (upstream / 'build').glob('*.sh'):
    if script.name == 'x265.sh':
        continue
    # Bound host resource usage; compilation flags and codec versions stay unchanged.
    text = re.sub(r'(?<= )-j(?=\s|$)', f'-j{args.jobs}', script.read_text())
    if script.name == 'freetype2.sh':
        # Prevent native host libpng/zlib discovery through *-config helpers.
        # The upstream isolated image does not provide these optional host libs.
        text = text.replace('--without-harfbuzz', '--without-harfbuzz --without-png --without-bzip2 --without-brotli --without-zlib')
    (work / 'build-scripts' / script.name).write_text(text)

def run(label, command, cwd, env):
    print(f'Building {label}; log: {work / "logs" / (label + ".log")}', flush=True)
    with (work / 'logs' / (label + '.log')).open('w') as log:
        result = subprocess.run(command, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT)
    if result.returncode:
        raise SystemExit(f'{label} failed ({result.returncode}); inspect its build log')

def environment(prefix):
    env = base.copy()
    flags = f'-I{prefix}/include -O3 -msimd128'
    pkg = f'{prefix}/lib/pkgconfig:{sdk}/upstream/emscripten/system/lib/pkgconfig'
    env.update(INSTALL_DIR=str(prefix), CFLAGS=flags, CXXFLAGS=flags,
               LDFLAGS=f'-L{prefix}/lib {flags}', EM_PKG_CONFIG_PATH=pkg,
               PKG_CONFIG_PATH=pkg, PKG_CONFIG_LIBDIR=pkg,
               EM_TOOLCHAIN_FILE=str(sdk / 'upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake'))
    return env

stages = [
    ('x264', 'x264', []), ('libvpx', 'libvpx', []), ('lame', 'lame', []),
    ('Ogg', 'ogg', []), ('theora', 'theora', ['Ogg']), ('opus', 'opus', []),
    ('vorbis', 'vorbis', ['Ogg']), ('zlib', 'zlib', []),
    ('libwebp', 'libwebp', ['zlib']), ('freetype2', 'freetype2', []),
    ('fribidi', 'fribidi', []), ('harfbuzz', 'harfbuzz', []),
    ('libass', 'libass', ['freetype2', 'fribidi', 'harfbuzz']), ('zimg', 'zimg', []),
]
for name, script, dependencies in stages:
    marker = work / 'completed' / name
    if args.resume and marker.exists() and marker.read_text() == sources[name]['sha256'] + '\n':
        print(f'Reusing completed {name}', flush=True)
        continue
    prefix = work / 'install' / name
    prefix.mkdir(exist_ok=True)
    for dep in dependencies:
        shutil.copytree(work / 'install' / dep, prefix, dirs_exist_ok=True)
    run(name, ['bash', str(work / 'build-scripts' / (script + '.sh'))], work / 'src' / name, environment(prefix))
    if not list((prefix / 'lib').glob('*.a')):
        raise SystemExit(f'{name} did not install static libraries')
    marker.write_text(sources[name]['sha256'] + '\n')

prefix = work / 'install/core'
prefix.mkdir(exist_ok=True)
for name, _, _ in stages:
    shutil.copytree(work / 'install' / name, prefix, dirs_exist_ok=True)
if list(prefix.rglob('*x265*')):
    raise SystemExit('Unexpected x265 file in the final link prefix')
core_env = environment(prefix)
ffmpeg = work / 'src/FFmpeg'
configure = [
    '--enable-gpl', '--enable-libx264', '--enable-libvpx', '--enable-libmp3lame',
    '--enable-libtheora', '--enable-libvorbis', '--enable-libopus', '--enable-zlib',
    '--enable-libwebp', '--enable-libfreetype', '--enable-libfribidi',
    '--enable-libass', '--enable-libzimg',
]
if not (args.resume and (work / 'completed/FFmpeg').exists()):
    run('FFmpeg', ['bash', str(work / 'build-scripts/ffmpeg.sh'), *configure], ffmpeg, core_env)
    (work / 'completed/FFmpeg').write_text(sources['FFmpeg']['sha256'] + '\n')
for name in ['bind', 'fftools']:
    shutil.copytree(upstream / 'src' / name, ffmpeg / 'src' / name, dirs_exist_ok=True)
libs = ['x264', 'vpx', 'mp3lame', 'ogg', 'theora', 'vorbis', 'vorbisenc', 'vorbisfile',
        'opus', 'z', 'webpmux', 'webp', 'sharpyuv', 'freetype', 'fribidi', 'harfbuzz', 'ass', 'zimg']
output = work / 'output'
output.mkdir(exist_ok=True)
run('link', ['bash', str(work / 'build-scripts/ffmpeg-wasm.sh'), *['-l' + lib for lib in libs],
             '-sEXPORT_ES6', '-Wl,-Map=' + str(output / 'ffmpeg-core.map'),
             '-o', str(output / 'ffmpeg-core.js')], ffmpeg, core_env)
provenance = {
    'buildId': '0.12.10-formatplease.1', 'upstreamCoreVersion': '0.12.10',
    'ffmpegVersion': '5.1.4', 'emscriptenVersion': '3.1.40',
    'buildScriptSha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
    'sourceCommits': {name: source['commit'] for name, source in sources.items()},
    'configureArguments': configure, 'linkedLibraries': libs,
    'files': [{'name': name, 'bytes': (output / name).stat().st_size,
               'sha256': hashlib.sha256((output / name).read_bytes()).hexdigest()}
              for name in ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.map']],
}
(output / 'build.json').write_text(json.dumps(provenance, indent=2) + '\n')
print(f'Built custom core in {output}. Capability and conversion verification is required before promotion.', flush=True)
