#!/usr/bin/env python3
"""Create a complete internal SD/NVMe GPT image as a new ordinary file."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import uuid

project = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(project / 'tools'))
from image_formats import digest, gpt, NAMESPACE, EFI_SYSTEM, LINUX_FILESYSTEM

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--boot', type=Path, default=project / 'out/fds-boot.img')
parser.add_argument('--recovery', type=Path, default=project / 'out/fds-recovery.img')
parser.add_argument('--machine-config', type=Path, default=project / 'config/machine')
parser.add_argument('--internal-mib', type=int, default=256)
parser.add_argument('--recovery-mib', type=int, choices=[1024, 2048], default=1024)
parser.add_argument('--output-directory', type=Path)
args = parser.parse_args()
if not 32 <= args.internal_mib <= 65536:
    parser.error('internal settings partition must be 32..65536 MiB')
boot, recovery = args.boot.resolve(strict=True), args.recovery.resolve(strict=True)
if not boot.is_file() or boot.stat().st_size != 512 * 1024 * 1024:
    parser.error('boot input must be a 512 MiB regular FAT32 partition image')
if not recovery.is_file() or not 4096 <= recovery.stat().st_size <= args.recovery_mib * 1024 * 1024:
    parser.error('recovery input must be a regular EROFS image fitting its partition')
with boot.open('rb') as stream:
    header = stream.read(512)
    if header[82:90] != b'FAT32   ' or header[71:82] != b'FDS_BOOT   ' or header[510:] != b'\x55\xaa':
        parser.error('boot input is not FDS_BOOT FAT32')
with recovery.open('rb') as stream:
    stream.seek(1024)
    header = stream.read(128)
    if header[:4] != bytes.fromhex('e2e1f5e0') or header[64:80].rstrip(b'\0') != b'FDS_RECOVERY':
        parser.error('recovery input is not FDS_RECOVERY EROFS')
work = args.output_directory or Path(tempfile.mkdtemp(prefix='internal-build.', dir=project / 'out'))
work = work.resolve(strict=True)
if not work.is_dir() or any(work.iterdir()):
    parser.error('output directory must exist and be empty')
subprocess.run([str(project / 'tools/cargo-build'), '--locked', '--offline', '--release', '--target', 'x86_64-unknown-linux-gnu', '-p', 'fds-cli'], cwd=project, check=True)
host_cli = project / 'target/x86_64-unknown-linux-gnu/release/fds'
tree = work / 'settings'
(tree / 'config').mkdir(parents=True)
(tree / 'diagnostics').mkdir(mode=0o700)
subprocess.run([str(host_cli), 'machine', 'pack', str(args.machine_config.resolve(strict=True)), str(tree / 'config/machine.json')], check=True)
epoch = int(subprocess.check_output(['git', '-C', str(project / 'vendor/void-packages'), 'show', '-s', '--format=%ct', 'HEAD']))
for path in [tree, *sorted(tree.rglob('*'))]:
    os.utime(path, (epoch, epoch))
runner = str(project / 'tools/in-image-tools')
subprocess.run([str(project / 'tools/prepare-image-tools')], check=True)
subprocess.run([runner, 'fsck.fat', '-n', str(boot)], check=True)
subprocess.run([runner, 'fsck.erofs', '--extract', str(recovery)], check=True)
settings = work / 'internal.ext4'
with settings.open('xb') as stream:
    stream.truncate(args.internal_mib * 1024 * 1024)
identity = uuid.uuid5(NAMESPACE, 'internal:' + digest(tree / 'config/machine.json'))
# Source ownership is root inside this unprivileged user namespace, independent
# of the workstation UID. No workstation block devices are exposed.
environment = {**os.environ, 'E2FSPROGS_FAKE_TIME': str(epoch)}
subprocess.run(['bwrap', '--unshare-user', '--uid', '0', '--gid', '0', '--ro-bind', '/', '/',
                '--bind', str(work), str(work), '--dev', '/dev', '--proc', '/proc',
                runner, 'mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-m', '0',
                '-L', 'FDS_INTERNAL', '-U', str(identity),
                '-E', f'root_owner=0:0,lazy_itable_init=0,lazy_journal_init=0,hash_seed={identity}',
                '-d', str(tree), str(settings)], env=environment, check=True)
# mke2fs copies source ctimes; normalize every populated inode after construction.
commands = work / 'normalize.debugfs'
commands.write_text(''.join(f'set_inode_field {name} {field} @{epoch}\n'
    for name in ['/', '/lost+found', '/config', '/config/machine.json', '/diagnostics']
    for field in ['atime', 'ctime', 'mtime', 'crtime']))
subprocess.run([runner, 'debugfs', '-w', '-f', str(commands), str(settings)], env=environment, check=True)
subprocess.run([runner, 'e2fsck', '-f', '-n', str(settings)], env=environment, check=True)
padded = work / 'recovery-partition.erofs'
shutil.copyfile(recovery, padded)
with padded.open('r+b') as stream:
    stream.truncate(args.recovery_mib * 1024 * 1024)
image = work / 'internal.img'
layout = gpt(image, [('FDS_BOOT', EFI_SYSTEM, boot), ('FDS_RECOVERY', LINUX_FILESYSTEM, padded), ('FDS_INTERNAL', LINUX_FILESYSTEM, settings)])
layout.update(format=1, image_sha256=digest(image), machine_settings_sha256=digest(tree / 'config/machine.json'),
              recovery_payload_sha256=digest(recovery), boot_payload_sha256=digest(boot))
observed = json.loads(subprocess.check_output(['sfdisk', '--json', str(image)]))['partitiontable']
assert observed['label'] == 'gpt' and len(observed['partitions']) == 3
for expected, actual in zip(layout['partitions'], observed['partitions']):
    assert (actual['name'], actual['start'], actual['size']) == (expected['name'], expected['start'], expected['size'])
subprocess.run(['sfdisk', '--verify', str(image)], check=True)
(work / 'layout.json').write_text(json.dumps(layout, indent=2) + '\n')
if args.output_directory is None:
    link = project / 'out/fds-internal.img.next'
    link.symlink_to(work.name + '/internal.img')
    link.replace(project / 'out/fds-internal.img')
print(f'PASS: complete internal SD/NVMe image, three verified GPT payloads: {work}')
print('SKIP: no physical disk was written; Pi firmware/SD boot requires hardware')
