#!/usr/bin/env python3
"""Build isolated macOS arm64 LGPL-only FFmpeg CLI tools from a pinned signed source."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import tarfile
import urllib.request

ROOT = Path(__file__).resolve().parents[1]
VERSION = '9.0.2'
ARCHIVE = f'ffmpeg-{VERSION}.tar.xz'
SHA256 = '8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e'
SIGNER = 'FCF986EA15E6E293A5644F10B4322F04D67658D8'
BASE = 'https://ffmpeg.org/releases/'
KEY_URL = 'https://ffmpeg.org/ffmpeg-devel.asc'
DEFAULT_WORK = ROOT / '.runtime/cetrika-dev/ffmpeg-lgpl'
FLAGS = [
    '--prefix=/setrika-ffmpeg', '--arch=aarch64', '--target-os=darwin', '--cc=clang',
    '--disable-autodetect', '--disable-everything', '--disable-gpl', '--disable-nonfree', '--disable-version3',
    '--disable-shared', '--enable-static', '--disable-debug', '--disable-doc', '--disable-ffplay',
    '--disable-avdevice', '--disable-swresample', '--enable-ffmpeg', '--enable-ffprobe',
    '--enable-network', '--enable-pthreads', '--enable-zlib', '--enable-securetransport',
    '--enable-protocol=file,pipe,tcp,udp,rtp,tls',
    '--enable-demuxer=mov,rtsp,rtp,sdp,h264,image2,image2pipe,mjpeg,image_jpeg_pipe,image_png_pipe',
    '--enable-muxer=mp4,mov,segment,image2,image2pipe,null',
    '--enable-decoder=h264,mjpeg,png', '--enable-encoder=mjpeg,png,wrapped_avframe',
    '--enable-parser=h264,mjpeg,png', '--enable-filter=select,scale,format,showinfo,null',
    '--enable-bsf=h264_mp4toannexb,extract_extradata',
    '--extra-cflags=-mmacosx-version-min=11.0', '--extra-ldflags=-mmacosx-version-min=11.0',
]


def sha(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def run(command, *, cwd=None, log=None, env=None):
    if log:
        with log.open('a') as output:
            subprocess.run(command, cwd=cwd, env=env, stdout=output, stderr=subprocess.STDOUT, check=True)
    else:
        subprocess.run(command, cwd=cwd, env=env, check=True)


def download(url, path):
    if path.exists():
        return
    request = urllib.request.Request(url, headers={'User-Agent': 'Setrika-FFmpeg-source-build/1'})
    temporary = path.with_suffix(path.suffix + '.part')
    with urllib.request.urlopen(request, timeout=60) as response, temporary.open('wb') as output:
        total = 0
        while chunk := response.read(1024 * 1024):
            total += len(chunk)
            if total > 100 * 1024 * 1024:
                raise ValueError('Unexpectedly large FFmpeg source download')
            output.write(chunk)
    temporary.replace(path)


def verify_source(archive, signature, key, home):
    if sha(archive) != SHA256:
        raise ValueError('FFmpeg source SHA256 mismatch')
    home.mkdir(exist_ok=True); home.chmod(0o700)
    command = [shutil.which('gpg') or 'gpg', '--homedir', str(home), '--batch']
    run(command + ['--import', str(key)])
    result = subprocess.run(command + ['--status-fd', '1', '--verify', str(signature), str(archive)], capture_output=True, text=True, check=True)
    valid = [line.split()[2] for line in result.stdout.splitlines() if line.startswith('[GNUPG:] VALIDSIG ')]
    if valid != [SIGNER]:
        raise ValueError('FFmpeg signature does not match pinned official fingerprint')
    return result.stdout


def safe_extract(archive, destination):
    with tarfile.open(archive) as source:
        for item in source.getmembers():
            path = Path(item.name)
            if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0] != f'ffmpeg-{VERSION}' or item.issym() or item.islnk() or item.isdev():
                raise ValueError('Unsafe source archive member')
        source.extractall(destination, filter='data')


def verify_extracted_source(archive, source):
    expected = set()
    with tarfile.open(archive) as contents:
        for item in contents.getmembers():
            if item.isfile():
                relative = Path(item.name).relative_to(f'ffmpeg-{VERSION}')
                expected.add(relative)
                path = source / relative
                if path.is_symlink() or not path.is_file() or path.stat().st_size != item.size:
                    raise ValueError('Source tree differs from signed archive')
                if hashlib.sha256(contents.extractfile(item).read()).digest() != hashlib.sha256(path.read_bytes()).digest():
                    raise ValueError('Source tree differs from signed archive')
    actual = {path.relative_to(source) for path in source.rglob('*') if path.is_file() or path.is_symlink()}
    if actual != expected:
        raise ValueError('Unexpected files in source tree')


def build(work, jobs):
    if platform.system() != 'Darwin' or platform.machine() != 'arm64':
        raise RuntimeError('This qualified recipe builds only macOS arm64; Windows is a separate acceptance gate')
    work = work.resolve(); work.mkdir(parents=True, exist_ok=True)
    downloads = work / 'downloads'; downloads.mkdir(exist_ok=True)
    archive, signature, key = downloads / ARCHIVE, downloads / (ARCHIVE + '.asc'), downloads / 'ffmpeg-devel.asc'
    for url, path in ((BASE + ARCHIVE, archive), (BASE + ARCHIVE + '.asc', signature), (KEY_URL, key)):
        download(url, path)
    verification = verify_source(archive, signature, key, work / 'gnupg')
    source = work / f'ffmpeg-{VERSION}'
    if not source.exists(): safe_extract(archive, work)
    verify_extracted_source(archive, source)
    # This tree must remain unmodified upstream source. Build outside it.
    build_dir = work / 'build'; build_dir.mkdir(exist_ok=True)
    log = work / 'build.log'
    env = {**os.environ, 'LC_ALL': 'C', 'SOURCE_DATE_EPOCH': '1789699562', 'ZERO_AR_DATE': '1'}
    for variable in ('CFLAGS', 'CPPFLAGS', 'CXXFLAGS', 'LDFLAGS', 'LIBRARY_PATH', 'CPATH', 'C_INCLUDE_PATH', 'CPLUS_INCLUDE_PATH', 'PKG_CONFIG_PATH'):
        env.pop(variable, None)
    env['PKG_CONFIG_LIBDIR'] = '/nonexistent-setrika-no-external-pkgconfig'
    configure = [str(source / 'configure'), *FLAGS]
    run(configure, cwd=build_dir, log=log, env=env)
    run(['make', f'-j{jobs}'], cwd=build_dir, log=log, env=env)
    run(['make', f'DESTDIR={work / "stage"}', 'install'], cwd=build_dir, log=log, env=env)
    prefix = work / 'stage/setrika-ffmpeg'
    notices = prefix / 'share/setrika-ffmpeg'; notices.mkdir(parents=True, exist_ok=True)
    for path in (archive, signature, key): shutil.copy2(path, notices / path.name)
    for name in ('LICENSE.md', 'COPYING.LGPLv2.1', 'CREDITS'):
        if (source / name).exists(): shutil.copy2(source / name, notices / name)
    for name in ('jfdctfst.c', 'jfdctint_template.c', 'jrevdct.c'):
        shutil.copy2(source / 'libavcodec' / name, notices / name)
    for path in (ROOT / 'assets/third-party/ffmpeg').glob('*.txt'):
        shutil.copy2(path, notices / path.name)
    (notices / 'source-verification.txt').write_text(verification)
    (notices / 'changes.diff').write_text('')
    (notices / 'BUILD.json').write_text(json.dumps({'version': VERSION, 'source_url': BASE + ARCHIVE, 'source_sha256': SHA256,
        'pgp_fingerprint': SIGNER, 'configure': ['./configure', *FLAGS], 'platform': 'macOS arm64',
        'minimum_deployment_target': '11.0', 'patches': [], 'jobs': jobs,
        'compiler': subprocess.check_output(['clang', '--version'], text=True).splitlines()[0],
        'binaries': {name: sha(prefix / 'bin' / name) for name in ('ffmpeg', 'ffprobe')}}, ensure_ascii=False, indent=2) + '\n')
    shutil.copy2(ROOT / 'tools/build_setrika_ffmpeg.py', notices / 'build_setrika_ffmpeg.py')
    shutil.copy2(ROOT / 'tools/audit_setrika_ffmpeg.py', notices / 'audit_setrika_ffmpeg.py')
    run([shutil.which('python3') or 'python3', str(ROOT / 'tools/audit_setrika_ffmpeg.py'), '--ffmpeg', str(prefix / 'bin/ffmpeg'), '--ffprobe', str(prefix / 'bin/ffprobe'), '--output', str(notices / 'AUDIT.json')])
    print(json.dumps({'prefix': str(prefix), 'ffmpeg': str(prefix / 'bin/ffmpeg'), 'ffprobe': str(prefix / 'bin/ffprobe'), 'notices': str(notices)}, indent=2))


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--work-dir', type=Path, default=DEFAULT_WORK)
    parser.add_argument('--jobs', type=int, default=min(6, os.cpu_count() or 2))
    args = parser.parse_args()
    if not 1 <= args.jobs <= 32: parser.error('--jobs must be 1..32')
    build(args.work_dir, args.jobs)
