#!/usr/bin/env python3
# Porteus VirtualBox builder
# Authors: Hamza & Brokenman
# Python port: Blaze

import os, sys, subprocess, re, tempfile, shutil, signal, time, fnmatch, tarfile
import urllib.request, threading
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib, GdkPixbuf

VBOX = 'https://download.virtualbox.org/virtualbox'
IMGS = '/mnt/live/memory/images'
PKGDIR = '/var/lib/pkgtools/packages'
ICON = '/usr/share/pixmaps/porteus/vbox.png'

T = {
    'en': {
        'title': 'VirtualBox builder for Porteus', 'host': 'VirtualBox Host (for running VMs)',
        'guest': 'Guest Additions (inside VM)',
        'desc': 'Select a file or download a version.\nThe file will be converted to a Porteus module.',
        'file:': 'Select file:', 'dl': 'Download latest version', 'browse': 'Browse',
        'cancel': 'Cancel', 'ok': 'OK', 'close': 'Close', '_cancel': '_Cancel', '_open': '_Open',
        'no_opt': 'Select file or download!', 'no_file': 'File not found',
        'all': 'All files', 'f_iso': 'ISO images (*.iso)', 'f_vbox': 'VirtualBox files (*.run, *.iso)',
        'no_ver': 'Could not determine version from filename.\nRename file to include version, e.g.:\n  VBoxGuestAdditions_7.2.14.iso\n  VirtualBox-7.2.14-Linux_amd64.run',
        'no_link': 'Could not get link',
        'downloading': 'Downloading', 'building': 'Building', 'preparing': 'Preparing environment...',
        'installing': 'Installing VirtualBox...', 'copying': 'Copying files...',
        'finalizing': 'Finalizing...', 'mkmod': 'Creating XZM module...',
        'mnt_fail': 'Failed to mount ISO', 'no_additions': 'VBoxLinuxAdditions.run not found in ISO',
        'ready': 'Module ready:', 'fail': 'Build failed',
        'mv_act': 'Move and activate', 'open_dir': 'Open folder',
        'moved': 'moved to', 'act_ok': 'successfully activated', 'act_ng': 'activation failed',
        'mv_fail': 'Move error', 'act_fail': 'Activation failed',
        'action': 'Choose action:', 'more': 'You can build more or close.',
        'no_moddir': 'Modules folder not found', 'low_space': 'Low space in',
        'need': 'Need', 'free': 'Free', 'root': 'Root required!', 'x11': 'X session required!',
        'sel_ver': 'Select version', 'version': 'Version',
        'installed': 'Installed VirtualBox version', 'na': 'n/a',
        'dl_fail': 'Download failed', 'show_all': 'Show all versions',
        'no_src': 'Kernel sources not found.\nPlease get and activate module:\n  06-crippled_sources-*.xzm\n(available on Porteus server or USB)',
        'no_tools': 'Build tools not found (gcc/make).\nPlease get and activate module:\n  05-devel-*.xzm\n(available on Porteus server or USB)',
        'stage_fail': 'Staging build failed. The live system was not modified.',
        'extracting': 'Extracting installer...', 'compiling': 'Compiling kernel modules...',
        'staging': 'Staging files...',
        'reboot_guest': 'Recommended: reboot or log out/in.',
        'reboot_host': 'Recommended: reboot or run init script.',
    },
    'ru': {
        'title': 'Сборка VirtualBox для Porteus', 'host': 'Хост VirtualBox (для запуска ВМ)',
        'guest': 'Гостевые дополнения (внутри ВМ)',
        'desc': 'Выберите файл или загрузите версию.\nФайл будет преобразован в модуль Porteus.',
        'file:': 'Выберите файл:', 'dl': 'Загрузить последнюю версию', 'browse': 'Указать файл',
        'cancel': 'Отмена', 'ok': 'ОК', 'close': 'Закрыть', '_cancel': '_Отмена', '_open': '_Открыть',
        'no_opt': 'Выберите файл или загрузку!', 'no_file': 'Файл не найден',
        'all': 'Все файлы', 'f_iso': 'ISO-образы (*.iso)', 'f_vbox': 'Файлы VirtualBox (*.run, *.iso)',
        'no_ver': 'Не удалось определить версию из имени файла.\nПереименуйте файл, включив версию, например:\n  VBoxGuestAdditions_7.2.14.iso\n  VirtualBox-7.2.14-Linux_amd64.run',
        'no_link': 'Не удалось получить ссылку',
        'downloading': 'Загрузка', 'building': 'Сборка', 'preparing': 'Подготовка окружения...',
        'installing': 'Установка VirtualBox...', 'copying': 'Копирование файлов...',
        'finalizing': 'Финализация...', 'mkmod': 'Создание модуля XZM...',
        'mnt_fail': 'Не удалось смонтировать ISO', 'no_additions': 'VBoxLinuxAdditions.run не найден в ISO',
        'ready': 'Модуль готов:', 'fail': 'Сборка не удалась',
        'mv_act': 'Переместить и активировать', 'open_dir': 'Открыть папку',
        'moved': 'перемещён в', 'act_ok': 'успешно активирован', 'act_ng': 'активация не удалась',
        'mv_fail': 'Ошибка перемещения', 'act_fail': 'Ошибка активации',
        'action': 'Выберите действие:', 'more': 'Можно собрать ещё или закрыть.',
        'no_moddir': 'Папка модулей не найдена', 'low_space': 'Мало места в',
        'need': 'Нужно', 'free': 'Свободно', 'root': 'Нужен root!', 'x11': 'Нужна X-сессия!',
        'sel_ver': 'Выбор версии', 'version': 'Версия',
        'installed': 'Установленная версия VirtualBox', 'na': 'н/д',
        'dl_fail': 'Ошибка загрузки', 'show_all': 'Показывать все версии',
        'no_src': 'Исходники ядра не найдены.\nПолучите и активируйте модуль:\n  06-crippled_sources-*.xzm\n(доступен на сервере Porteus или USB)',
        'no_tools': 'Инструменты сборки не найдены (gcc/make).\nПолучите и активируйте модуль:\n  05-devel-*.xzm\n(доступен на сервере Porteus или USB)',
        'stage_fail': 'Изолированная сборка не удалась. Живая система не была изменена.',
        'extracting': 'Извлечение установщика...', 'compiling': 'Компиляция модулей ядра...',
        'staging': 'Подготовка файлов...',
        'reboot_guest': 'Рекомендуется перезагрузка или повторный вход.',
        'reboot_host': 'Рекомендуется перезагрузка или запуск init-скрипта.',
    }
}

def _(k): return T.get(os.environ.get('LANG', 'en')[:2], T['en']).get(k, k)
def safe_rm(p):
    try: os.remove(p)
    except: pass

def cmd(c, sh=False, t=60, env=None, input_data=None):
    try:
        kw = dict(shell=sh, capture_output=True, text=True,
                  errors='replace', timeout=t, env=env)
        if input_data is None:
            kw['stdin'] = subprocess.DEVNULL
        else:
            kw['input'] = input_data
        return subprocess.run(c, **kw)
    except subprocess.TimeoutExpired:
        return subprocess.CompletedProcess(c, 124, '', f'Timeout after {t}s')
    except Exception as e:
        return subprocess.CompletedProcess(c, 1, '', str(e))

def load_icon(size=48):
    try: return GdkPixbuf.Pixbuf.new_from_file_at_size(ICON, size, size)
    except: return None

def pver():
    try:
        m = re.search(r'(\d+\.\d+(?:\.\d+)?)', open('/etc/porteus-version').read().strip())
        return f'v{m.group(1)}' if m else 'unknown'
    except: return 'unknown'

def is_vbox_file(f, kind=None):
    f = os.path.basename(f)
    if not f.endswith('.xzm'): return False
    if kind == 'guest': return f.startswith('vbox-guest-')
    if kind == 'host': return f.startswith('VirtualBox-')
    return f.startswith(('VirtualBox-', 'vbox-guest-'))

def find_vbox_mods(kind=None):
    r = []
    for d in (IMGS, os.environ.get('MODDIR', '')):
        if d and os.path.isdir(d):
            try: r.extend(os.path.join(d, f) for f in os.listdir(d) if is_vbox_file(f, kind))
            except: pass
    return r

def vbox_in_images(kind=None):
    try: return os.path.isdir(IMGS) and any(is_vbox_file(f, kind) for f in os.listdir(IMGS))
    except: return False

def deactivate_silent(m):
    if os.path.isfile(m): cmd(['deactivate', m, '-q'], t=30)

def deactivate_all_wait(kind=None):
    for m in find_vbox_mods(kind): deactivate_silent(m)
    for _ in range(15):
        if not vbox_in_images(kind): break
        time.sleep(1)

def pkg_ver():
    if not os.path.isdir(PKGDIR): return None, None
    try:
        for f in os.listdir(PKGDIR):
            m = re.match(r'(?:VirtualBox|vbox-guest)-(\d+\.\d+\.\d+)', f)
            if m: return m.group(1), f
    except: pass
    return None, None

def space_ok(p, mb=800):
    try:
        s = os.statvfs(p); free = s.f_bavail * s.f_frsize // (1024 * 1024)
        return free >= mb, free
    except: return True, 0

def is_vm():
    try:
        for p in ('/sys/class/dmi/id/product_name', '/sys/class/dmi/id/sys_vendor'):
            if os.path.exists(p) and any(x in open(p).read() for x in ('VirtualBox', 'Oracle', 'innotek')):
                return True
    except: pass
    return 'VirtualBox' in cmd(['/usr/sbin/dmidecode', '-s', 'system-product-name'], t=5).stdout

def fm_open(path):
    try:
        subprocess.Popen(['xdg-open', path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                         stdin=subprocess.DEVNULL, start_new_session=True)
        return True
    except: return False

def mod_dir():
    d = os.environ.get('MODDIR', '')
    if d and os.path.isdir(d): return d
    for p in (IMGS, '/porteus/modules', '/mnt/sda1/porteus/modules'):
        if os.path.isdir(p): return p
    return None

def find_ga_cd():
    try:
        for d in sorted(os.listdir('/mnt')):
            if d.startswith('VBox_GAs_'):
                base = os.path.join('/mnt', d)
                if os.path.exists(os.path.join(base, 'VBoxLinuxAdditions.run')):
                    return base
    except: pass
    return None

def vbox_version_lists(mode='host', arch='x86_64'):
    try:
        vs = sorted(re.findall(r'href="(\d+\.\d+\.\d+)/"',
                               urllib.request.urlopen(f'{VBOX}/', timeout=15).read().decode()),
                    key=lambda v: [int(x) for x in v.split('.')], reverse=True)
        max32 = (5, 2, 44) if mode == 'host' and arch not in ('x86_64', 'amd64') else None
        all_out, latest, seen = [], [], set()
        for v in vs:
            try: parts = tuple(int(x) for x in v.split('.'))
            except: continue
            if max32 and parts > max32: continue
            all_out.append(v); key = '.'.join(v.split('.')[:2])
            if key not in seen: seen.add(key); latest.append(v)
        return latest, all_out
    except: return [], []

def vbox_url(v, a, dt):
    tg = 'amd64' if a == 'x86_64' else 'x86'
    if dt == 'vbox':
        try:
            for l in re.findall(r'href="([^"]+\.run)"',
                                urllib.request.urlopen(f'{VBOX}/{v}/', timeout=10).read().decode()):
                if tg in l: return f'{VBOX}/{v}/{l}'
        except: pass
        return f'{VBOX}/{v}/VirtualBox-{v}-Linux_{tg}.run'
    return f'{VBOX}/{v}/VBoxGuestAdditions_{v}.iso'

def mount_iso(p, mnt):
    r = cmd(['mount', '-o', 'loop', p, mnt], t=30)
    return r.returncode == 0 and os.path.ismount(mnt)

def inst_ver():
    try:
        r = cmd(['VBoxManage', '--version'], t=5)
        if r.returncode == 0:
            m = re.match(r'(\d+\.\d+\.\d+)', r.stdout.strip())
            if m: return m.group(0)
    except: pass
    v, _ = pkg_ver(); return v

def inst_short():
    f = inst_ver(); m = re.match(r'(\d+\.\d+\.\d+)', f) if f else None
    return m.group(1) if m else None

def clean_tmp_files():
    for f in ('/tmp/.message', '/tmp/.cdload', '/tmp/vboxguest-Module.symvers'): safe_rm(f)

def find_file(root, name):
    for dp, dn, fn in os.walk(root):
        if name in fn: return os.path.join(dp, name)
    return None

def find_files_pattern(root, pattern):
    return [os.path.join(dp, f) for dp, dn, fn in os.walk(root)
            for f in fn if fnmatch.fnmatch(f.lower(), pattern.lower())]

def extract_tar_compat(tar, path):
    try: tar.extractall(path, filter='data')
    except TypeError: tar.extractall(path)

def strip_unwanted(path):
    bad_dirs = {'sdk', 'doc', 'docs', 'manual', 'src', 'source', 'sources', 'dtrace',
                'additions', 'unattendedtemplates', 'init'}
    bad_files = ('*.cmd', '*.o', '*.mod', '*.mod.c', '*.order', '*.symvers', '*.c', '*.h',
                 '*.cpp', '*.cc', '*.s', '*.S', 'Makefile', 'Makefile.*', 'Kbuild', '*.debug',
                 '*.pdb', '*.sym', '*.map', '*.a', '*.la', '*.spec', 'UserManual.pdf',
                 'UserManual.qch', 'UserManual.qhc', 'postinst-common.sh', 'prerm-common.sh',
                 'routines.sh', 'check_module_dependencies.sh', 'VBox.sh', 'uninstall.sh',
                 '.autoreg', 'vboxshell.py', 'VBoxPython2.so', 'VBoxPython2_7.so', 'VBoxDTrace')
    old_drv = ('vboxmouse_drv_13.so', 'vboxmouse_drv_14.so', 'vboxmouse_drv_15.so',
               'vboxmouse_drv_16.so', 'vboxmouse_drv_70.so', 'vboxmouse_drv_71.so',
               'vboxvideo_drv_13.so', 'vboxvideo_drv_14.so', 'vboxvideo_drv_15.so',
               'vboxvideo_drv_16.so', 'vboxvideo_drv_17.so', 'vboxvideo_drv_18.so',
               'vboxvideo_drv_19.so', 'vboxvideo_drv_70.so', 'vboxvideo_drv_71.so',
               'vboxvideo_drv_110.so', 'vboxvideo_drv_111.so', 'vboxvideo_drv_112.so',
               'vboxvideo_drv_113.so', 'vboxvideo_drv_114.so', 'vboxvideo_drv_115.so',
               'vboxvideo_drv_116.so', 'vboxvideo_drv_117.so', 'vboxvideo_drv_118.so',
               'x11config.sh', '98vboxadd-xclient', 'pam_vbox.so', 'vbox_accel.pp',
               'vbox_x11.pp', 'VBoxAudioTest')
    for root, dirs, files in os.walk(path, topdown=False):
        for d in list(dirs):
            if d.lower() in bad_dirs:
                shutil.rmtree(os.path.join(root, d), ignore_errors=True); dirs.remove(d)
        for f in files:
            lf = f.lower()
            if any(fnmatch.fnmatch(lf, p.lower()) for p in bad_files) or f in old_drv:
                safe_rm(os.path.join(root, f))
    for root, dirs, files in os.walk(path, topdown=False):
        try:
            if not os.listdir(root): os.rmdir(root)
        except: pass

class Prog(Gtk.Dialog):
    def __init__(self, par, t, m):
        super().__init__(title=t, transient_for=par, modal=True)
        self.set_deletable(False)
        self.set_default_size(340, 110); self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
        b = self.get_content_area(); b.set_spacing(12)
        b.set_margin_top(20); b.set_margin_bottom(20)
        try: b.set_margin_start(24); b.set_margin_end(24)
        except: b.set_margin_left(24); b.set_margin_right(24)
        self.lbl = Gtk.Label(label=m); self.lbl.set_line_wrap(True)
        self.bar = Gtk.ProgressBar(); self.bar.set_pulse_step(0.08)
        b.pack_start(self.lbl, False, False, 0); b.pack_start(self.bar, False, False, 0)
        self.show_all(); self._tid = GLib.timeout_add(80, self._tk); self._d = False
    def _tk(self):
        if not self._d: self.bar.pulse(); return True
        return False
    def msg(self, m):
        if not self._d: self.lbl.set_text(m)
    def close(self):
        if not self._d:
            self._d = True
            if self._tid: GLib.source_remove(self._tid)
            self.destroy()

class VerDlg(Gtk.Dialog):
    def __init__(self, par, latest, all_vs, iv):
        super().__init__(title=_('sel_ver'), transient_for=par, modal=True)
        self.set_default_size(360, 430); self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
        b = self.get_content_area(); b.set_spacing(8)
        b.set_margin_top(12); b.set_margin_bottom(12)
        try: b.set_margin_start(16); b.set_margin_end(16)
        except: b.set_margin_left(16); b.set_margin_right(16)
        il = Gtk.Label(); il.set_markup(f'<b>{_("installed")}: {iv or _("na")}</b>')
        il.set_halign(Gtk.Align.START); b.pack_start(il, False, False, 0)
        self.latest, self.all = latest, all_vs
        self.chk_all = Gtk.CheckButton(label=_('show_all')); self.chk_all.connect('toggled', self._fill)
        b.pack_start(self.chk_all, False, False, 0)
        sw = Gtk.ScrolledWindow(); sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.st = Gtk.ListStore(str); self.tv = Gtk.TreeView(model=self.st)
        self.tv.append_column(Gtk.TreeViewColumn(_('version'), Gtk.CellRendererText(), text=0))
        self.tv.set_headers_visible(True); self.tv.get_selection().set_mode(Gtk.SelectionMode.SINGLE)
        sw.add(self.tv); b.pack_start(sw, True, True, 0); self._fill()
        self.add_button(_('_cancel'), Gtk.ResponseType.CANCEL); self.add_button(_('ok'), Gtk.ResponseType.OK)
        self.show_all()
    def _fill(self, *a):
        self.st.clear()
        for v in (self.all if self.chk_all.get_active() else self.latest): self.st.append([v])
        if len(self.st): self.tv.get_selection().select_path(Gtk.TreePath.new_first())
    def chosen(self):
        _, it = self.tv.get_selection().get_selected()
        return self.st[it][0] if it else None

class App(Gtk.Window):
    def __init__(self, cli_mode=None, cli_file=None):
        super().__init__(title=_('title'))
        self._icon = load_icon(48)
        if self._icon: self.set_icon(self._icon)
        self.set_border_width(12); self.set_resizable(True); self.set_default_size(640, 360)
        self.set_position(Gtk.WindowPosition.CENTER); self.connect('destroy', Gtk.main_quit)
        raw = os.uname().machine
        self.arch = 'i586' if raw in ('i386', 'i486', 'i586', 'i686') else raw
        self.lib = 'lib64' if self.arch == 'x86_64' else 'lib'
        self.mode, self._prog = None, None
        self.lang_codes = self._get_lang_codes(); self._ui()
        if cli_mode == 'guest': self.rg.set_active(True); self.mode = 'guest'
        elif cli_mode == 'host': self.rh.set_active(True); self.mode = 'host'
        else:
            self.mode = 'guest' if is_vm() else 'host'
            if self.mode == 'guest': self.rg.set_active(True)
            else: self.rh.set_active(True)
        if self.mode == 'guest':
            cd = find_ga_cd()
            if cd: self._set_path(cd)
        if cli_file and os.path.isfile(cli_file): self._set_path(cli_file)

    def _get_lang_codes(self):
        codes = []
        for var in ('LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG'):
            for item in os.environ.get(var, '').split(':'):
                item = item.split('.')[0]
                if item and item not in ('C', 'POSIX'):
                    codes.append(item)
                    if '_' in item: codes.append(item.split('_')[0])
        return list(dict.fromkeys(codes))

    def _ui(self):
        vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12); self.add(vb)
        hb = Gtk.Box(spacing=12)
        if self._icon: hb.pack_start(Gtk.Image.new_from_pixbuf(self._icon), False, False, 0)
        t = Gtk.Label(); t.set_markup(f'<span size="x-large" weight="bold">{_("title")}</span>')
        hb.pack_start(t, True, True, 0)
        vb.pack_start(hb, False, False, 0); vb.pack_start(Gtk.Separator(), False, False, 0)
        d = Gtk.Label(label=_('desc')); d.set_line_wrap(True); d.set_max_width_chars(80)
        d.set_halign(Gtk.Align.START); vb.pack_start(d, False, False, 0)
        rb = Gtk.Box(spacing=20)
        self.rh = Gtk.RadioButton.new_with_label_from_widget(None, _('host'))
        self.rh.connect('toggled', self._sm, 'host')
        self.rg = Gtk.RadioButton.new_with_label_from_widget(self.rh, _('guest'))
        self.rg.connect('toggled', self._sm, 'guest')
        rb.pack_start(self.rh, False, False, 0); rb.pack_start(self.rg, False, False, 0)
        vb.pack_start(rb, False, False, 0)
        fh = Gtk.Box(spacing=6); fh.pack_start(Gtk.Label(label=_('file:')), False, False, 0)
        self.ent = Gtk.Entry(); self.ent.set_editable(False); self.ent.set_width_chars(52)
        fh.pack_start(self.ent, True, True, 0)
        bb = Gtk.Button(label=_('browse')); bb.connect('clicked', self._br)
        fh.pack_start(bb, False, False, 0); vb.pack_start(fh, False, False, 0)
        self.chk = Gtk.CheckButton(label=_('dl'))
        self.chk.connect('toggled', lambda w: self.ent.set_sensitive(not w.get_active()))
        vb.pack_start(self.chk, False, False, 0)
        vb.pack_start(Gtk.Separator(), False, False, 4)
        bh = Gtk.Box(spacing=8, halign=Gtk.Align.END)
        bc = Gtk.Button(label=_('cancel')); bc.connect('clicked', lambda w: Gtk.main_quit())
        bo = Gtk.Button(label=_('ok')); bo.connect('clicked', self._ok)
        bh.pack_end(bo, False, False, 0); bh.pack_end(bc, False, False, 0)
        vb.pack_end(bh, False, False, 0); self.show_all()

    def _set_path(self, p):
        p = p or ''; self.ent.set_text(p); self.ent.set_tooltip_text(p)
    def _sm(self, b, m):
        if b.get_active(): self.mode = m; self.ent.set_text(''); self.ent.set_tooltip_text('')

    def _br(self, w):
        d = Gtk.FileChooserDialog(title=_('browse'), parent=self, action=Gtk.FileChooserAction.OPEN)
        d.add_buttons(_('_cancel'), Gtk.ResponseType.CANCEL, _('_open'), Gtk.ResponseType.OK)
        fa = Gtk.FileFilter(); fa.set_name(_('all')); fa.add_pattern('*'); d.add_filter(fa)
        pt, nm = ('*.iso', _('f_iso')) if self.mode == 'guest' else ('*.run;*.iso', _('f_vbox'))
        ff = Gtk.FileFilter(); ff.set_name(nm)
        for p in pt.split(';'): ff.add_pattern(p)
        d.add_filter(ff); d.set_filter(ff); d.set_current_folder('/mnt')
        if d.run() == Gtk.ResponseType.OK: self._set_path(d.get_filename()); self.chk.set_active(False)
        d.destroy()

    def _ok(self, w):
        if self.chk.get_active(): return self._pick()
        p = self.ent.get_text().strip()
        if not p: self._err(_('no_opt'))
        elif not os.path.exists(p): self._err(_('no_file'))
        else: self._set_path(p); self._build(p, False)

    def _msg(self, mt, tx):
        def f():
            bt = Gtk.ButtonsType.OK if mt == Gtk.MessageType.INFO else Gtk.ButtonsType.CLOSE
            d = Gtk.MessageDialog(transient_for=self, modal=True, message_type=mt, buttons=bt, text=tx)
            d.run(); d.destroy(); return False
        GLib.idle_add(f)
    def _err(self, m): self._msg(Gtk.MessageType.ERROR, m)
    def _info(self, m): self._msg(Gtk.MessageType.INFO, m)
    def _po(self, t, m):
        def f():
            if self._prog: self._prog.close()
            self._prog = Prog(self, t, m); return False
        GLib.idle_add(f)
    def _pm(self, m): GLib.idle_add(lambda: (self._prog and self._prog.msg(m)) or False)
    def _pc(self):
        def f():
            if self._prog: self._prog.close(); self._prog = None
            return False
        GLib.idle_add(f)

    def _thread(self, fn, *args):
        threading.Thread(target=fn, args=args, daemon=True).start()

    def _pick(self):
        self._po(_('downloading'), _('downloading'))
        def w():
            latest, all_vs = vbox_version_lists(self.mode, self.arch); iv = inst_short()
            GLib.idle_add(self._after_pick, latest, all_vs, iv)
        self._thread(w)
    def _after_pick(self, latest, all_vs, iv):
        self._pc()
        if latest: self._verdlg(latest, all_vs, iv)
        else: self._err(_('no_link'))
    def _verdlg(self, latest, all_vs, iv):
        d = VerDlg(self, latest, all_vs, iv); r = d.run(); v = d.chosen(); d.destroy()
        if r == Gtk.ResponseType.OK and v: self._dlver(v)

    def _dlver(self, ver):
        self._po(_('downloading'), _('downloading'))
        def w():
            url = vbox_url(ver, self.arch, 'guest' if self.mode == 'guest' else 'vbox')
            if not url: GLib.idle_add(self._err, _('no_link')); GLib.idle_add(self._pc); return
            fn = os.path.basename(url); dest = os.path.join('/tmp', fn); dl = False
            if not (os.path.exists(dest) and os.path.getsize(dest) > 1048576):
                self._pm(f'{_("downloading")} {fn}...')
                try:
                    with urllib.request.urlopen(url, timeout=600) as r, open(dest, 'wb') as f:
                        shutil.copyfileobj(r, f)
                    dl = True
                except Exception as e:
                    safe_rm(dest); GLib.idle_add(self._err, f'{_("dl_fail")}: {e}')
                    GLib.idle_add(self._pc); return
            GLib.idle_add(self._pc); GLib.idle_add(self._set_path, dest)
            GLib.idle_add(self._build, dest, dl)
        self._thread(w)

    def _build(self, fp, dl=False):
        self._po(_('building'), _('preparing'))
        def w():
            try: self._do(fp, dl)
            except Exception as e: GLib.idle_add(self._pc); GLib.idle_add(self._err, f'{_("fail")}: {e}')
        self._thread(w)

    def _file_version(self, fn):
        m = re.search(r'(?:VBoxGuestAdditions[_-]|VirtualBox-)(\d+\.\d+\.\d+(?:[-_.][^\s/\\]+)?)', fn)
        if m:
            clean = re.search(r'(\d+\.\d+\.\d+)', m.group(1))
            if clean: return clean.group(1)
        m = re.search(r'(\d+\.\d+\.\d+)', fn)
        return m.group(1) if m else None

    def _elf_class(self, path):
        try:
            with open(path, 'rb') as f: data = f.read(5)
            if data[:4] == b'\x7fELF': return data[4]
        except: pass
        return None

    def _prepare_kern(self):
        ksrc = '/usr/src/linux'
        fixdep = os.path.join(ksrc, 'scripts', 'basic', 'fixdep')
        want = 2 if self.arch == 'x86_64' else 1
        if self._elf_class(fixdep) == want: return
        print(f'[vbox-builder] kernel host tools arch mismatch/missing; rebuilding for {self.arch}')
        cmd(['make', '-C', ksrc, 'scripts'], t=900)
        cmd(['make', '-C', ksrc, 'prepare'], t=900)

    def _mki(self, bd):
        os.makedirs(os.path.join(bd, 'etc/rc.d/init.d'), exist_ok=True)
        os.makedirs(os.path.join(bd, 'etc/rc.d/rc4.d'), exist_ok=True)
        if self.mode == 'guest':
            ip, ln = os.path.join(bd, 'etc/rc.d/init.d/rc.vbox-add'), 'rc.vbox-add'
            body = '#!/bin/sh\n/sbin/modprobe vboxguest 2>/dev/null\n/sbin/modprobe vboxsf 2>/dev/null\n/sbin/modprobe vboxvideo 2>/dev/null\n/usr/sbin/VBoxService\n'
        else:
            ip, ln = os.path.join(bd, 'etc/rc.d/init.d/rc.virtualbox'), 'rc.virtualbox'
            body = '#!/bin/sh\n/sbin/depmod -a\n/sbin/modprobe vboxdrv\n/sbin/modprobe vboxnetadp\n/sbin/modprobe vboxnetflt\n'
        open(ip, 'w').write(body); os.chmod(ip, 0o755)
        cmd(['ln', '-sf', f'/etc/rc.d/init.d/{ln}',
             os.path.join(bd, 'etc/rc.d/rc4.d', 'S99' + ln.replace('rc.', ''))])

    def _link(self, bd, target, link_path):
        if not target or not os.path.exists(target): return
        link_abs = os.path.join(bd, link_path.lstrip('/'))
        os.makedirs(os.path.dirname(link_abs), exist_ok=True)
        try:
            if os.path.lexists(link_abs): os.remove(link_abs)
            os.symlink('/' + os.path.relpath(target, bd), link_abs)
        except: pass

    def _write_text(self, path, text, mode=0o755):
        os.makedirs(os.path.dirname(path), exist_ok=True)
        open(path, 'w').write(text); os.chmod(path, mode)

    def _copy_to(self, src, dst):
        if src and os.path.exists(src):
            os.makedirs(dst, exist_ok=True)
            try: shutil.copy2(src, dst)
            except: pass

    def _payload_root(self, src, markers):
        for m in markers:
            marker = find_file(src, m)
            if marker:
                d = os.path.dirname(marker)
                return os.path.dirname(d) if d != src and os.path.basename(d).lower() in ('bin', 'sbin') else d
        return src

    def _strip_nls(self, opt):
        nls = os.path.join(opt, 'nls')
        if not os.path.isdir(nls): return
        for f in os.listdir(nls):
            if not f.endswith('.qm'): continue
            base = f[:-3]
            if not (base.startswith(('VirtualBox_en', 'qt_en')) or
                    any(base == f'VirtualBox_{l}' or base == f'qt_{l}' for l in self.lang_codes)):
                safe_rm(os.path.join(nls, f))

    def _extract_tar(self, tp, dest):
        try:
            with tarfile.open(tp, 'r:*') as tar: extract_tar_compat(tar, dest)
            return True
        except Exception as e:
            print(f'[vbox-builder] tarfile failed for {os.path.basename(tp)}: {e}')
        r = cmd(['tar', '-xf', tp, '-C', dest], t=600)
        if r.returncode == 0: return True
        print(f'[vbox-builder] system tar failed rc={r.returncode}: {(r.stderr or "")[:300]}')
        return False

    def _extract_tarballs(self, src):
        tballs = find_files_pattern(src, '*.tar.bz2') + find_files_pattern(src, '*.tar.gz')
        print(f'[vbox-builder] tarballs found: {[os.path.basename(t) for t in tballs]}')
        want = 'amd64' if self.arch == 'x86_64' else 'x86'
        tballs.sort(key=lambda t: 0 if want in os.path.basename(t).lower() else 1)
        for tf in tballs:
            td = os.path.join(src, f'ex_{os.path.basename(tf)}')
            os.makedirs(td, exist_ok=True)
            if self._extract_tar(tf, td): return td
        return src

    def _find_mod_src(self, root, mod):
        ml = mod.lower()
        for dp, dn, fn in os.walk(root):
            if 'Makefile' in fn and os.path.basename(dp).lower().startswith(ml): return dp
        for dp, dn, fn in os.walk(root):
            if 'Makefile' in fn and any(x.lower().startswith(ml) and x.lower().endswith('.c') for x in fn):
                return dp
        return None

    def _make_dir(self, d, src, chk):
        kver = os.uname().release
        env = os.environ.copy()
        env.update({'PATH': '/usr/sbin:/usr/bin:/sbin:/bin',
                    'KERN_DIR': '/usr/src/linux', 'KERN_VER': kver, 'VBOX_GUEST_SRC': d})
        parent = os.path.dirname(d)
        attempts = [
            ['make', '-C', d, f'KERN_DIR=/usr/src/linux', f'KERN_VER={kver}'],
            ['make', '-C', d],
            ['make', '-C', parent, f'KERN_DIR=/usr/src/linux', f'KERN_VER={kver}'],
            ['make', '-C', parent],
            ['make', '-C', '/usr/src/linux', f'M={d}', 'modules'],
        ]
        last = None
        for a in attempts:
            r = cmd(a, t=1800, env=env); last = r
            if chk and (find_file(src, chk) or find_files_pattern(src, chk + '*')): return True
        if last is not None:
            print(f'[vbox-builder] make failed for {d}\n  last rc={last.returncode}\n'
                  f'  stdout tail: {(last.stdout or "")[-600:]}\n'
                  f'  stderr tail: {(last.stderr or "")[-600:]}')
        return False

    def _compile_guest(self, src, bd):
        kver = os.uname().release
        dest = os.path.join(bd, 'lib/modules', kver, 'misc'); os.makedirs(dest, exist_ok=True)
        for mod in ('vboxguest', 'vboxsf', 'vboxvideo'):
            d = self._find_mod_src(src, mod)
            if not d:
                if mod != 'vboxvideo': return False
                continue
            if self._make_dir(d, src, f'{mod}.ko'):
                for ko in find_files_pattern(src, f'{mod}.ko*'): self._copy_to(ko, dest)
            elif mod != 'vboxvideo':
                return False
            else:
                print(f'[vbox-builder] NOTE: {mod} not built (kernel {kver} newer than VBox DRM '
                      f'support); skipped - graphics via VMSVGA/VBoxDRMClient')
        return all(find_files_pattern(dest, f'{m}.ko*') for m in ('vboxguest', 'vboxsf'))

    def _compile_host(self, src, bd):
        kver = os.uname().release
        dest = os.path.join(bd, 'lib/modules', kver, 'misc'); os.makedirs(dest, exist_ok=True)
        d = None
        for dp, dn, fn in os.walk(src):
            if 'Makefile' in fn and 'vboxhost' in os.path.basename(dp).lower(): d = dp; break
        if not d:
            for dp, dn, fn in os.walk(src):
                if 'Makefile' in fn and any('vboxdrv' in x.lower() for x in fn): d = dp; break
        print(f'[vbox-builder] vboxhost src dir: {d}')
        if not d: return False
        self._make_dir(d, src, 'vboxdrv.ko')
        for ko in find_files_pattern(src, '*.ko*'): self._copy_to(ko, dest)
        return bool(find_files_pattern(dest, 'vboxdrv.ko*'))

    def _stage_guest(self, src, bd, fv):
        self._pm(_('compiling')); src = self._extract_tarballs(src)
        if not self._compile_guest(src, bd): return False
        self._pm(_('staging')); opt = os.path.join(bd, 'opt', f'VBoxGuestAdditions-{fv}')
        try: shutil.copytree(self._payload_root(src, ['VBoxService']), opt, symlinks=True, dirs_exist_ok=True)
        except: return False
        strip_unwanted(opt); strip_unwanted(bd)
        self._link(bd, find_file(opt, 'VBoxService'), '/usr/sbin/VBoxService')
        self._link(bd, find_file(opt, 'VBoxClient'), '/usr/bin/VBoxClient')
        self._link(bd, find_file(opt, 'VBoxControl'), '/usr/bin/VBoxControl')
        self._link(bd, find_file(opt, 'mount.vboxsf'), '/sbin/mount.vboxsf')
        vca = find_file(opt, 'VBoxClient-all')
        if vca: self._link(bd, vca, '/usr/bin/VBoxClient-all')
        else: self._write_text(os.path.join(bd, 'usr/bin/VBoxClient-all'),
                               '#!/bin/sh\nexec /usr/bin/VBoxClient --all "$@"\n', 0o755)
        drv = find_file(opt, 'vboxvideo_drv.so')
        if drv: self._copy_to(drv, os.path.join(bd, 'usr', self.lib, 'xorg/modules/drivers'))
        dri = find_file(opt, 'vboxvideo_dri.so')
        if dri:
            self._copy_to(dri, os.path.join(bd, 'usr', self.lib, 'dri'))
            self._copy_to(dri, os.path.join(bd, 'usr', self.lib, 'xorg/modules/dri'))
        for ogl in find_files_pattern(opt, 'VBoxOGL*.so*'): self._copy_to(ogl, os.path.join(bd, 'usr', self.lib))
        desk = find_file(opt, 'vboxclient.desktop')
        dtxt = '[Desktop Entry]\nType=Application\nName=VirtualBox Guest Additions\nComment=VirtualBox Guest Additions\nExec=/usr/bin/VBoxClient-all\nTerminal=false\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n'
        if desk:
            self._copy_to(desk, os.path.join(bd, 'etc/xdg/autostart'))
            self._copy_to(desk, os.path.join(bd, 'usr/share/autostart'))
        else:
            self._write_text(os.path.join(bd, 'etc/xdg/autostart/vboxclient.desktop'), dtxt, 0o644)
            self._write_text(os.path.join(bd, 'usr/share/autostart/vboxclient.desktop'), dtxt, 0o644)
        self._mki(bd); return True

    def _stage_host(self, src, bd, fv):
        self._pm(_('compiling')); src = self._extract_tarballs(src)
        if not self._compile_host(src, bd): return False
        self._pm(_('staging')); opt = os.path.join(bd, 'opt', 'VirtualBox')
        try: shutil.copytree(self._payload_root(src, ['VirtualBox', 'VBoxManage']), opt,
                             symlinks=True, dirs_exist_ok=True)
        except: return False
        strip_unwanted(opt); self._strip_nls(opt); strip_unwanted(bd)
        for b in ('VirtualBox', 'VirtualBoxVM', 'VBoxManage', 'VBoxHeadless', 'VBoxSDL',
                  'VBoxNetAdpCtl', 'VBoxNetDHCP', 'VBoxNetNAT', 'VBoxVolInfo', 'rdesktop-vrdp'):
            self._link(bd, find_file(opt, b), f'/usr/bin/{b}')
        vm, vb = os.path.join(opt, 'VirtualBoxVM'), os.path.join(opt, 'VirtualBox')
        if os.path.exists(vm): os.chmod(vm, 0o4755)
        if os.path.exists(vb): os.chmod(vb, 0o755)
        self._mki(bd); return True

    def _do(self, fp, dl=False):
        ok, free = space_ok('/tmp', 800)
        if not ok: self._pc(); self._err(f'{_("low_space")} /tmp\n{_("need")}: 800 MB, {_("free")}: {free} MB'); return
        if not os.path.exists('/usr/src/linux'): self._pc(); self._err(_('no_src')); return
        self._prepare_kern()
        if not shutil.which('gcc') or not shutil.which('make'): self._pc(); self._err(_('no_tools')); return
        fv = self._file_version(os.path.basename(fp))
        if not fv: self._pc(); self._err(_('no_ver')); return
        self._do_staging(fp, dl, fv)

    def _do_staging(self, fp, dl, fv):
        with tempfile.TemporaryDirectory(prefix='vbox-stage-') as td:
            src, bd, iso = os.path.join(td, 'src'), os.path.join(td, 'build'), os.path.join(td, 'iso')
            os.makedirs(src, exist_ok=True); os.makedirs(bd, exist_ok=True); os.makedirs(iso, exist_ok=True)
            mounted, inst = False, fp
            try:
                self._pm(_('extracting'))
                if self.mode == 'host' and fp.lower().endswith('.iso'):
                    self._pc(); self._err(_('no_file')); return
                run_src = None
                if self.mode == 'guest':
                    if fp.lower().endswith('.iso'):
                        if not mount_iso(fp, iso): self._pc(); self._err(_('mnt_fail')); return
                        mounted = True
                        run_src = os.path.join(iso, 'VBoxLinuxAdditions.run')
                    elif os.path.isdir(fp):
                        run_src = os.path.join(fp, 'VBoxLinuxAdditions.run')
                if run_src:
                    if not os.path.exists(run_src): self._pc(); self._err(_('no_additions')); return
                    inst = os.path.join(td, 'VBoxLinuxAdditions.run')
                    shutil.copy2(run_src, inst); os.chmod(inst, 0o755)
                env = os.environ.copy()
                env.update({'TERM': 'dumb', 'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'SETUP_NOCHECK': '1'})
                ok_ex = False
                for args in ([['sh', inst, '--quiet', '--accept', '--noexec', '--keep',
                               '--noprogress', '--nox11', '--nochown', '--nodiskspace', '--target', src],
                              ['sh', inst, '--accept', '--noexec', '--keep', '--target', src],
                              ['sh', inst, '--noexec', '--keep', '--target', src],
                              ['sh', inst, '--noexec', '--target', src]]):
                    r = cmd(args, t=900, env=env, input_data='yes\n')
                    print(f'[vbox-builder] extract rc={r.returncode}: {" ".join(args)}')
                    if r.returncode != 0:
                        print(f'  stdout: {(r.stdout or "")[:300]}\n  stderr: {(r.stderr or "")[:300]}')
                    if os.path.isdir(src) and os.listdir(src): ok_ex = True; break
                if not ok_ex: self._pc(); self._err(_('stage_fail')); return
                ok_st = self._stage_guest(src, bd, fv) if self.mode == 'guest' else self._stage_host(src, bd, fv)
                if not ok_st: self._pc(); self._err(_('stage_fail')); return
                self._package(bd, fv, dl, fp)
            finally:
                if mounted and os.path.ismount(iso): cmd(['umount', iso], t=20)
                clean_tmp_files()

    def _build_ok(self, bd):
        if self.mode == 'guest':
            return (os.path.exists(os.path.join(bd, 'etc/rc.d/init.d/rc.vbox-add')) and
                    bool(find_file(bd, 'VBoxService')) and bool(find_files_pattern(bd, 'vboxguest.ko*')))
        return (os.path.exists(os.path.join(bd, 'etc/rc.d/init.d/rc.virtualbox')) and
                bool(find_file(bd, 'VirtualBox')) and bool(find_files_pattern(bd, 'vboxdrv.ko*')))

    def _package(self, bd, fv, dl=False, fp=None):
        if not self._build_ok(bd): self._pc(); self._err(_('fail')); return False
        self._pm(_('mkmod')); pn = 'vbox-guest' if self.mode == 'guest' else 'VirtualBox'
        out = f'/tmp/{pn}-{fv}-porteus-{pver()}-{self.arch}-1prt.xzm'
        safe_rm(out); self._mkpkg(bd, pn, fv)
        r = cmd(['dir2xzm', bd, out], t=600)
        if r.returncode != 0: self._pc(); self._err(f'{_("fail")}: {r.stderr}'); return False
        self._pc()
        if dl and fp: safe_rm(fp)
        GLib.idle_add(self._result, out); return True

    def _mkpkg(self, bd, pn, ver):
        pd = os.path.join(bd, 'var/lib/pkgtools/packages'); os.makedirs(pd, exist_ok=True)
        nm = f'{pn}-{ver}-porteus-{pver()}-{self.arch}-1prt'
        with open(os.path.join(pd, nm), 'w') as f:
            f.write(f'PACKAGE NAME: {nm}\nPACKAGE DESCRIPTION:\n')
            for l in [f'{pn}: {pn} (virtualization software)', f'{pn}:',
                      f'{pn}: Oracle VM VirtualBox is a cross-platform virtualization',
                      f'{pn}: application that allows you to run multiple operating systems',
                      f'{pn}: simultaneously on a single physical computer.', f'{pn}:',
                      f'{pn}: https://www.virtualbox.org/', f'{pn}:', 'FILE LIST:']:
                f.write(l + '\n')
            for root, _, files in os.walk(bd):
                for fn in files:
                    rel = os.path.relpath(os.path.join(root, fn), bd)
                    if not rel.startswith('var/lib/pkgtools/packages'): f.write(rel + '\n')

    def _result(self, out):
        d = Gtk.MessageDialog(transient_for=self, modal=True, message_type=Gtk.MessageType.INFO,
                              buttons=Gtk.ButtonsType.NONE, text=f'{_("ready")}\n{out}')
        d.set_property('secondary-text', f'{_("action")}\n{_("more")}')
        d.add_button(_('mv_act'), 1); d.add_button(_('open_dir'), 2); d.add_button(_('close'), 3)
        r = d.run(); d.destroy()
        if r == 1: self._thread(self._move_act, out)
        elif r == 2: fm_open('/tmp')
        else: Gtk.main_quit()
        return False

    def _move_act(self, out):
        md = mod_dir()
        if not md: GLib.idle_add(self._err, _('no_moddir')); return
        try:
            mn, dest = os.path.basename(out), os.path.join(md, os.path.basename(out))
            deactivate_all_wait(self.mode)
            if not os.path.isfile(out): GLib.idle_add(self._err, f'{_("mv_fail")}: {out}'); return
            GLib.idle_add(self._po, _('building'), _('mv_act'))
            shutil.move(out, dest); os.sync()
            r = cmd(['activate', dest], t=60); GLib.idle_add(self._pc)
            extra = _('reboot_guest') if self.mode == 'guest' else _('reboot_host')
            if r.returncode == 0: GLib.idle_add(self._info, f'{mn}\n{_("moved")} {md}\n{_("act_ok")}\n\n{extra}')
            else: GLib.idle_add(self._err, f'{_("act_fail")}\n{r.stderr.strip() or r.stdout.strip() or _("act_ng")}')
        except Exception as e: GLib.idle_add(self._pc); GLib.idle_add(self._err, f'{_("mv_fail")}: {e}')

def parse_args():
    mode = fp = None
    if len(sys.argv) > 1:
        a = sys.argv[1].lower()
        if a in ('vbox', 'host'): mode = 'host'
        elif a in ('vboxguest', 'guest'): mode = 'guest'
    if len(sys.argv) > 2 and os.path.isfile(sys.argv[2]): fp = sys.argv[2]
    return mode, fp

if __name__ == '__main__':
    if os.getuid() != 0: print(_('root')); sys.exit(1)
    if not os.environ.get('DISPLAY'): print(_('x11')); sys.exit(1)
    pix = load_icon(48)
    if pix:
        try: Gtk.Window.set_default_icon(pix)
        except: pass
    signal.signal(signal.SIGINT, lambda *_: Gtk.main_quit())
    GLib.set_prgname('vbox-builder'); GLib.set_application_name(_('title'))
    cm, cf = parse_args(); App(cm, cf).show_all(); Gtk.main()
