#!/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 load_plan(path):
    data = json.loads(Path(path).read_text())
    keys = {'format', 'disk', 'hostname', 'username', 'timezone'}
    if not isinstance(data, dict) or set(data) != keys or data.get('format') != 'eifyd-install-v1':
        raise ValueError('Unsupported installation plan; expected eifyd-install-v1 fields only.')
    if not all(isinstance(value, str) for value in data.values()):
        raise ValueError('Installation plan fields must be text.')
    return settings(argparse.Namespace(**data))


def review_plan(plan):
    disk = target(plan)
    print("\nE’ifyd installation review")
    print(f"Target: {plan['disk']} ({int(disk['size']) / 1024**3:.1f} GiB)")
    print('Action: erase the entire target; Alpine creates its system layout and bootloader.')
    print('Existing home partitions on this disk will NOT be preserved.')
    print(f"Computer: {plan['hostname']} | Account: {plan['username']} | Timezone: {plan['timezone']}")
    print('Packages: current native APK selection, E desktop and Flatpak applications.')
    print('Password is requested at installation and is never saved in the plan.')


def tui():
    print("E’ifyd console installer — whole-disk installation")
    available = [d for d in disks() if d['type'] == 'disk']
    for disk in available:
        mounted = any(any(n.get('mountpoints') or []) for n in children(disk))
        print(f"  {disk['path']}: {int(disk['size']) / 1024**3:.1f} GiB"
              + (' [in use — unavailable]' if mounted else ''))
    args = argparse.Namespace(
        disk=input('Target disk (blank cancels): ').strip(),
        hostname='', username='', timezone='UTC')
    if not args.disk:
        return
    args.hostname = input('Computer name [alpine-workstation]: ').strip() or 'alpine-workstation'
    args.username = input('Your account name: ').strip()
    args.timezone = input('Timezone [UTC]: ').strip() or 'UTC'
    plan = settings(args)
    review_plan(plan)
    choice = input('[i] Install, [s] Save plan, [Enter] Cancel: ').strip().lower()
    if choice == 'i':
        install(plan)
    elif choice == 's':
        path = input('New plan filename: ').strip()
        # Exclusive creation avoids overwriting an existing plan or following a symlink.
        with open(path, 'x') as output:
            json.dump(plan, output, indent=2)
            output.write('\n')
        print('Plan saved; no disk changes made.')


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)
    review_plan(plan)
    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', 'tui'])
    p.add_argument('--plan', help='Read a saved installation plan (no passwords)')
    p.add_argument('--disk')
    p.add_argument('--hostname')
    p.add_argument('--username')
    p.add_argument('--timezone', default='UTC')
    args = p.parse_args()
    if args.action == 'tui':
        if args.plan or args.disk or args.hostname or args.username:
            p.error('tui collects its own settings')
        tui()
        return
    if args.plan:
        if args.disk or args.hostname or args.username or args.timezone != 'UTC':
            p.error('Do not mix --plan with individual settings')
        plan = load_plan(args.plan)
    else:
        if not all((args.disk, args.hostname, args.username)):
            p.error('Supply --plan or --disk, --hostname and --username')
        plan = settings(args)
    if args.action == 'review':
        review_plan(plan)
        print('Native setup-disk will install this workstation. No changes made by review.')
    else:
        install(plan)


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