#!/usr/bin/env python3
"""Assemble and sign a versioned local release from a verified offline build pair."""
import argparse
import importlib.machinery
import importlib.util
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile

from release_artifacts import artifacts, compare, digest, read_lock

project = Path(__file__).resolve().parents[1]


def packed_tree(source, destination, epoch):
    def normalized(info):
        info.uid = info.gid = 0
        info.uname = info.gname = ''
        info.mtime = epoch
        info.pax_headers = {}
        if not (info.isdir() or info.isfile() or info.issym() or info.islnk()):
            raise ValueError(f'Special file in release source: {info.name}')
        return info
    # Single-threaded Zstandard and ordered normalized tar metadata provide a
    # deterministic archive without loading the input snapshot into memory.
    with destination.open('xb') as output:
        compressor = subprocess.Popen(['zstd', '-q', '-T1', '-10', '-c'], stdin=subprocess.PIPE, stdout=output)
        try:
            with tarfile.open(fileobj=compressor.stdin, mode='w|', format=tarfile.PAX_FORMAT) as archive:
                for path in sorted(source.iterdir()):
                    archive.add(path, arcname=path.name, filter=normalized)
            compressor.stdin.close()
            if compressor.wait() != 0:
                raise ValueError('Release archive compression failed')
        finally:
            if compressor.poll() is None:
                compressor.kill()
                compressor.wait()


def run(args):
    snapshot = args.inputs.absolute()
    output = args.output_directory.absolute()
    key = args.key.absolute()
    if key.is_symlink() or not key.is_file():
        raise ValueError('Signing key must be a regular file, not a symlink')
    if output.exists() or output.is_symlink():
        raise ValueError('Release output must be a new directory')
    if key.is_relative_to(output) or key.is_relative_to(snapshot):
        raise ValueError('Signing key must be outside the release and frozen-input directories')
    loader = importlib.machinery.SourceFileLoader('fds_frozen_inputs', str(project / 'tools/frozen-inputs'))
    spec = importlib.util.spec_from_loader(loader.name, loader)
    frozen = importlib.util.module_from_spec(spec)
    loader.exec_module(frozen)
    lock = frozen.verify(snapshot)
    _, lock_hash = read_lock(project)
    if lock_hash != digest(snapshot / 'lock.json'):
        raise ValueError('Current build uses a different frozen input snapshot')
    # Documentation is part of source identity too. Refuse to sign binaries
    # under the identity of a checkout changed after freezing its source.
    with tempfile.TemporaryDirectory(prefix='release-source-check.', dir=project / 'out') as temporary:
        current = Path(temporary)
        for name in frozen.OWNED:
            frozen.copy(project / name, current / 'project' / name)
        if frozen.source_digest(frozen.inventory(current)) != lock['source_sha256']:
            raise ValueError('Current source differs from the frozen release source')
    comparison = compare(project, args.comparison_build)
    if comparison['status'] != 'passed':
        raise ValueError('Independent build differs; run tools/compare-builds for a mismatch report')
    for profile in ('cli', 'development', 'recovery'):
        root = (project / f'out/rootfs-{profile}.tar').resolve(strict=True).parent
        for name in ('build-inputs.sha256', 'fds-tools-inputs.sha256'):
            subprocess.run(['sha256sum', '--quiet', '-c', str(root / name)], cwd=project, check=True)
    files = artifacts(project)
    version, epoch = lock['version'], lock['source_epoch']
    signer = project / 'target/x86_64-unknown-linux-gnu/release/fds-release'
    if not signer.is_file():
        raise ValueError('Build the host signer with make signing first')
    output.mkdir(mode=0o700)
    for name, source in files.items():
        # Rootfs tars remain in the reproducibility record. Their complete
        # contents are already represented in the installable images.
        if name.startswith('rootfs-'):
            continue
        if name.endswith('.xbps'):
            target = name
        elif name.startswith('initramfs.cpio'):
            target = f'fds-initramfs-{version}.cpio' + name.removeprefix('initramfs.cpio')
        else:
            stem, suffix = name.rsplit('.', 1)
            target = f'{stem}-{version}.{suffix}'
        shutil.copyfile(source, output / target)
    packed_tree(snapshot, output / f'fds-build-inputs-{version}.tar.zst', epoch)
    packed_tree(snapshot / 'project', output / f'fds-source-{version}.tar.zst', epoch)
    (output / 'reproducibility.json').write_text(json.dumps(comparison, indent=2) + '\n')
    shutil.copyfile(snapshot / 'lock.json', output / 'build-inputs.json')
    for profile in ('cli', 'development', 'recovery'):
        root = (project / f'out/rootfs-{profile}.tar').resolve(strict=True).parent
        shutil.copyfile(root / 'packages.json', output / f'packages-{profile}.json')
    shutil.copyfile(project / 'fds-os.wiki/Releases.md', output / 'RELEASE-VERIFICATION.md')
    shutil.copyfile(project / 'fds-os.wiki/Internal-Storage.md', output / 'INSTALLATION.md')
    shutil.copyfile(project / 'fds-os.wiki/EEPROM.md', output / 'EEPROM.md')
    shutil.copyfile(project / 'tools/release-readme.md', output / 'README.md')
    shutil.copyfile(project / 'LICENSE', output / 'LICENSE')
    subprocess.run([str(signer), 'public-key', str(key), str(output / 'signer.pub')], check=True)
    manifest = {'format': 1, 'version': version, 'source_epoch': epoch,
                'source_sha256': lock['source_sha256'], 'void_commit': lock['void_commit'],
                'hardware_validation': 'deferred', 'files': []}
    for path in sorted(output.iterdir()):
        manifest['files'].append({'name': path.name, 'bytes': path.stat().st_size, 'sha256': digest(path)})
    if len(manifest['files']) > 64:
        raise ValueError('Release exceeds the bounded manifest artifact count')
    (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
    subprocess.run([str(signer), 'sign', str(output), '--key', str(key)], check=True)
    subprocess.run([str(signer), 'verify', str(output), '--key', str(output / 'signer.pub')], check=True)
    print(f'PASS: signed and verified local FDS/OS {version} release: {output}')
    print('Hardware validation is deferred. Publishing and hardware installation are separate actions.')
    print('Distribute the public-key fingerprint independently; the bundled key alone does not establish trust.')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--inputs', type=Path, required=True)
    parser.add_argument('--comparison-build', type=Path, required=True)
    parser.add_argument('--output-directory', type=Path, required=True)
    parser.add_argument('--key', type=Path, required=True)
    args = parser.parse_args()
    if args.comparison_build.resolve() == project:
        parser.error('Comparison must be a second independently restored build tree')
    try:
        run(args)
    except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
        sys.exit(f'ERROR: {error}')


if __name__ == '__main__':
    main()
