#!/usr/bin/python3
"""E'ifyd: reviewed desktop settings around Alpine's native setup-disk.

No partitioner or APK installer lives here. setup-disk owns both. This adapter
prepares its documented apkovl input and preserves the workstation extras.
"""
import argparse
import getpass
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import time


def run(*args, **kwargs):
    return subprocess.run(args, check=True, **kwargs)


def settings(args):
    for value in (args.hostname, args.username):
        if not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', value):
            raise ValueError('Use a lowercase name beginning with a letter.')
    if args.username in ('root', 'alpine'):
        raise ValueError('Choose your own account name, different from the live account.')
    if not re.fullmatch(r'/dev/[A-Za-z0-9_-]+', args.disk):
        raise ValueError('Select a whole disk, for example /dev/sdb or /dev/nvme1n1.')
    zone = Path('/usr/share/zoneinfo') / args.timezone
    if not re.fullmatch(r'[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*', args.timezone) or not zone.is_file():
        raise ValueError('Select an installed timezone such as America/Los_Angeles.')
    return dict(format='eifyd-install-v1', disk=args.disk, hostname=args.hostname,
                username=args.username, timezone=args.timezone)


def disks():
    return json.loads(run('lsblk', '-J', '-b', '-o',
                         'PATH,TYPE,SIZE,FSTYPE,MOUNTPOINTS',
                         capture_output=True, text=True).stdout)['blockdevices']


def children(node):
    yield node
    for child in node.get('children', []):
        yield from children(child)


def target(plan):
    disk = next((d for d in disks() if d['path'] == plan['disk']), None)
    if not disk or disk['type'] != 'disk':
        raise ValueError('Target is not a whole disk.')
    if any(any(n.get('mountpoints') or []) for n in children(disk)):
        raise ValueError('Target has mounted filesystems or active swap; it cannot be erased.')
    used = shutil.disk_usage('/').used
    if int(disk['size']) < max(20 * 1024**3, used + 8 * 1024**3):
        raise ValueError('Target needs space for this workstation plus 8 GiB free; 100 GiB is recommended.')
    return disk


def configure_overlay(root, plan, password_hash, source=Path('/')):
    etc = root / 'etc'
    user = plan['username']
    passwd = (etc / 'passwd').read_text().splitlines()
    if any(line.split(':')[0] == user for line in passwd):
        raise ValueError('That account name is already reserved on this system.')
    live = next((line.split(':') for line in passwd if line.startswith('alpine:')), None)
    if not live:
        raise ValueError('Expected AlpinE live account is absent; refusing to guess account IDs.')
    uid, gid = live[2:4]
    passwd = [line for line in passwd if not line.startswith('alpine:')]
    passwd.append(f'{user}:x:{uid}:{gid}:{user}:/home/{user}:/bin/ash')
    (etc / 'passwd').write_text('\n'.join(passwd) + '\n')
    shadow = []
    for line in (etc / 'shadow').read_text().splitlines():
        fields = line.split(':')
        if fields[0] == 'alpine':
            continue
        if fields[0] == 'root':
            fields[1] = '!'
        shadow.append(':'.join(fields))
    shadow.append(f'{user}:{password_hash}:{int(time.time() // 86400)}:0:99999:7:::')
    (etc / 'shadow').write_text('\n'.join(shadow) + '\n')
    (etc / 'shadow').chmod(0o600)
    groups = []
    for line in (etc / 'group').read_text().splitlines():
        fields = line.split(':')
        if fields[0] == 'alpine':
            fields[0] = user
        fields[3] = ','.join(user if m == 'alpine' else m for m in fields[3].split(','))
        groups.append(':'.join(fields))
    (etc / 'group').write_text('\n'.join(groups) + '\n')
    for path in ('sudoers.d/alpine-live', 'doas.d/live.conf', 'machine-id'):
        (etc / path).unlink(missing_ok=True)
    for path in (etc / 'ssh').glob('ssh_host_*'):
        path.unlink()
    for path in (etc / 'lightdm').rglob('*.conf'):
        path.write_text('\n'.join(line for line in path.read_text().splitlines()
                                  if not line.strip().startswith('autologin-')) + '\n')
    (etc / 'sudoers.d').mkdir(exist_ok=True)
    rule = etc / 'sudoers.d/eifyd-wheel'
    rule.write_text('%wheel ALL=(ALL:ALL) ALL\n')
    rule.chmod(0o440)
    (etc / 'hostname').write_text(plan['hostname'] + '\n')
    (etc / 'timezone').write_text(plan['timezone'] + '\n')
    (etc / 'localtime').unlink(missing_ok=True)
    shutil.copyfile(source / 'usr/share/zoneinfo' / plan['timezone'], etc / 'localtime')
    home = root / 'home' / user
    shutil.rmtree(root / 'home/alpine', ignore_errors=True)
    shutil.rmtree(root / 'root/.ssh', ignore_errors=True)
    shutil.copytree(etc / 'skel', home, symlinks=True)
    for parent, dirs, files in os.walk(home):
        os.chown(parent, int(uid), int(gid))
        for name in dirs + files:
            os.chown(Path(parent) / name, int(uid), int(gid), follow_symlinks=False)
    # These image-owned helpers are outside APK; native package files stay APK-owned.
    shutil.copytree(source / 'usr/local', root / 'usr/local', symlinks=True, dirs_exist_ok=True)


def install(plan):
    if os.geteuid() != 0:
        raise ValueError('Run installation through sudo.')
    target(plan)
    print(json.dumps(plan, indent=2), flush=True)
    print('This installs the current native APK selection, E desktop and Flatpak applications.')
    phrase = 'ERASE ' + plan['disk']
    if input('Type ' + phrase + ' to replace this entire disk: ') != phrase:
        raise ValueError('Installation cancelled; no disk changes made.')
    password = getpass.getpass('Password for ' + plan['username'] + ': ')
    if len(password) < 8 or password != getpass.getpass('Repeat password: '):
        raise ValueError('Passwords must match and contain at least eight characters.')
    hashed = run('openssl', 'passwd', '-6', '-stdin', input=password + '\n',
                 text=True, capture_output=True).stdout.strip()
    del password
    with tempfile.TemporaryDirectory(prefix='eifyd-') as work:
        work = Path(work)
        overlay = work / 'root'
        overlay.mkdir()
        archive = work / 'desktop.apkovl.tar.gz'
        run('lbu', 'package', str(archive))
        run('tar', '-xzf', str(archive), '-C', str(overlay))
        configure_overlay(overlay, plan, hashed)
        run('tar', '-czf', str(archive), '-C', str(overlay), '.')
        target(plan)  # Recheck immediately before the native destructive operation.
        env = os.environ.copy()
        env['ERASE_DISKS'] = plan['disk']
        run('setup-disk', '-m', 'sys', '-s', '0', '-k', 'lts', '-o', str(archive),
            plan['disk'], env=env)
        disk = target(plan)
        roots = [n for n in children(disk) if n['type'] == 'part' and n['fstype'] == 'ext4']
        if not roots:
            raise ValueError('Native install finished but its ext4 root was not found.')
        rootpart = max(roots, key=lambda n: int(n['size']))
        mounted = work / 'installed'
        mounted.mkdir()
        run('mount', rootpart['path'], str(mounted))
        try:
            if (mounted / 'etc/hostname').read_text().strip() != plan['hostname']:
                raise ValueError('Mounted target does not match the installation plan.')
            if Path('/var/lib/flatpak').is_dir():
                (mounted / 'var/lib/flatpak').mkdir(parents=True, exist_ok=True)
                run('rsync', '-aHAX', '--info=progress2', '/var/lib/flatpak/',
                    str(mounted / 'var/lib/flatpak') + '/')
            run('sync')
        finally:
            run('umount', str(mounted))
    print('Installation complete. Shut down, remove the live media and boot the target.')


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument('action', choices=['review', 'install'])
    p.add_argument('--disk', required=True)
    p.add_argument('--hostname', required=True)
    p.add_argument('--username', required=True)
    p.add_argument('--timezone', default='UTC')
    args = p.parse_args()
    plan = settings(args)
    if args.action == 'review':
        target(plan)
        print(json.dumps(plan, indent=2))
        print('Native setup-disk will install this workstation. No changes made by review.')
    else:
        install(plan)


if __name__ == '__main__':
    try:
        main()
    except (ValueError, OSError, subprocess.CalledProcessError) as error:
        raise SystemExit(str(error))
