#!/usr/bin/env python3
import os, sys, shutil, subprocess, threading, time, urllib.request
from platform import machine

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, Pango

try:
    gi.require_version('Vte', '2.91')
    from gi.repository import Vte
    HAVE_VTE = True
except Exception:
    HAVE_VTE = False

IS64 = machine() == 'x86_64'
ARCH = 'x86_64' if IS64 else 'i586'
LIST_URL = 'http://dl.porteus.org/i586/testing/live/list.toml'
UPDATE_CMD = '/usr/bin/update'
RETRIES = 3
CARD_W, CARD_H = 180, 60
SAFE_CHARS = frozenset('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./')

LANG = next((os.environ.get(v, '')[:2].lower()
             for v in ('LC_ALL', 'LC_MESSAGES', 'LANG') if os.environ.get(v)), '')
_SUF = '_' + LANG if LANG else ''

# Fixed category metadata: (key, name, icons, hidden_locale_or_None)
CATEGORIES = (
    ('Network', 'Network', ('network-workgroup', 'folder-web', 'web-browser'), None),
    ('Office', 'Office', ('x-office-document', 'applications-office'), None),
    ('Graphics, audio and video', 'Graphics, audio and video', ('applications-multimedia', 'folder-music', 'applications-graphics'), None),
    ('System', 'System', ('preferences-system', 'applications-system', 'computer'), None),
    ('Russian services', 'Russian services', ('flag-ru', 'applications-internet', 'security-high'), 'ru'),
)
CAT_BY_KEY = {k: (name, icons, hide) for k, name, icons, hide in CATEGORIES}
CAT_ORDER = [k for k, *_ in CATEGORIES]

# UI strings only; module data comes from list.toml
TR = {'ru': {
    'Porteus Module Centre': 'Центр модулей Porteus',
    'Manage, update and build Porteus modules': 'Управление, обновление и сборка модулей Porteus',
    'Updates': 'Обновления', 'My Modules': 'Мои модули', 'Module Maker': 'Сборка модулей',
    'Bundles Downloader': 'Загрузка бандлов',
    'Manage your installed modules': 'Управление установленными модулями',
    'Build modules from packages': 'Сборка модулей из пакетов',
    'Download pre-built bundles': 'Загрузка готовых бандлов',
    'Launch': 'Запустить', 'Close': 'Закрыть', 'Command not found': 'Команда не найдена',
    'VTE is not available': 'VTE недоступен', 'Updating module': 'Обновление модуля',
    'Update command not found (/usr/bin/update).': 'Команда обновления не найдена (/usr/bin/update).',
    'OS architecture:': 'Архитектура ОС:', 'Refresh list': 'Обновить список',
    'Loading list…': 'Загрузка списка…', 'Load error:': 'Ошибка загрузки:',
    'Loaded entries:': 'Загружено записей:', 'architecture': 'архитектура',
    'Select a module from the list.': 'Выберите модуль из списка.',
    'Update selected': 'Обновить выбранный', 'Module': 'Модуль', 'Category': 'Категория',
    'Architecture': 'Архитектура', 'Modules list': 'Модули списком', 'Other': 'Другие',
    'required': 'требуется', 'Update already running.': 'Обновление уже выполняется.',
    'Search…': 'Поиск…', 'Files': 'Файлы', 'Open /tmp folder': 'Открыть папку /tmp',
    'Requires X or Wayland session.': 'Требуется X или Wayland сессия.',
    'Root privileges required.': 'Нужны права root.',
    'Network': 'Сеть', 'Office': 'Офис', 'System': 'Система',
    'Graphics, audio and video': 'Графика, аудио и видео',
    'Russian services': 'Российские сервисы',
}}
_TR_RU = TR.get('ru', {})
_ = lambda t: _TR_RU.get(t, t)


class AppContext:
    __slots__ = ('active_updates', '_scale', '_icon_cache')
    def __init__(self):
        self.active_updates = set()
        self._scale = None
        self._icon_cache = {}

    def scale(self):
        if self._scale is None:
            try:
                self._scale = Gdk.Display.get_default().get_primary_monitor().get_scale_factor()
            except Exception:
                self._scale = 1
        return self._scale


CTX = AppContext()


def icon(names, size=28):
    s = size * CTX.scale()
    cache_key = (names if isinstance(names, tuple) else (names,), s)
    cached = CTX._icon_cache.get(cache_key)
    if cached is not None:
        return cached
    theme = Gtk.IconTheme.get_default()
    result = None
    for n in cache_key[0]:
        if n.startswith('/'):
            if os.path.isfile(n):
                try:
                    result = Gtk.Image.new_from_pixbuf(
                        GdkPixbuf.Pixbuf.new_from_file_at_size(n, s, s))
                    break
                except Exception:
                    pass
        elif theme.has_icon(n):
            result = Gtk.Image.new_from_icon_name(n, Gtk.IconSize.INVALID)
            result.set_pixel_size(s)
            break
    if result is None:
        result = Gtk.Image.new_from_icon_name('application-x-executable', Gtk.IconSize.INVALID)
        result.set_pixel_size(s)
    CTX._icon_cache[cache_key] = result
    return result


def hbox(s=6): return Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=s)
def vbox(s=6): return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=s)

def label(text='', expand=False):
    l = Gtk.Label(label=text)
    l.set_xalign(0)
    l.set_hexpand(expand)
    return l

def make_button(text=None, icons=None, tooltip=None):
    b = Gtk.Button(label=text) if text else Gtk.Button()
    if icons:
        b.set_image(icon(icons, 16))
        b.set_always_show_image(True)
    if tooltip:
        b.set_tooltip_text(tooltip)
    return b

def tab_widget(text, icons):
    b = hbox(4)
    b.pack_start(icon(icons, 16), False, False, 0)
    b.pack_start(Gtk.Label(label=text), False, False, 0)
    b.show_all()
    return b


def clear_on_escape(entry, event):
    if event.keyval == Gdk.KEY_Escape:
        entry.set_text('')
        return True
    return False


def show_error(parent, text):
    d = Gtk.MessageDialog(transient_for=parent, modal=True,
                          message_type=Gtk.MessageType.ERROR,
                          buttons=Gtk.ButtonsType.OK, text=text)
    d.run()
    d.destroy()


def parse_records(text):
    """Parse '[N] key = value' blocks into a list of dicts."""
    records = []
    current = None
    for line in text.splitlines():
        line = line.strip()
        if not line or line[0] == '#':
            continue
        if line[0] == '[' and line[-1] == ']':
            current = {}
            records.append(current)
        elif '=' in line and current is not None:
            k, v = line.split('=', 1)
            k = k.strip()
            v = v.strip()
            if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
                v = v[1:-1]
            current[k] = v
    return records


def loc(rec, field):
    return rec.get(field + _SUF) or rec.get(field, '')


def icon_tuple(rec):
    return tuple(x.strip() for x in rec.get('icons', '').split(',') if x.strip())


def build_modules(records):
    """Validate keys and arch, resolve localized fields."""
    safe = SAFE_CHARS
    mods = []
    for rec in records:
        key = rec.get('name', '')
        if not key or not all(c in safe for c in key):
            continue
        arch = rec.get('arch', 'all')
        if arch not in ('all', '32', '64'):
            arch = 'all'
        mods.append({
            'key': key,
            'name': loc(rec, 'name') or key,
            'desc': loc(rec, 'desc'),
            'icons': icon_tuple(rec),
            'arch': arch,
            'category': rec.get('category', ''),
            'dev': rec.get('dev', 'false').strip().lower() == 'true',
        })
    return mods


def arch_ok(arch):
    return not ((IS64 and arch == '32') or (not IS64 and arch == '64'))


def cat_visible(cat_key):
    info = CAT_BY_KEY.get(cat_key)
    if info is None:
        return True
    _, _, hide = info
    return hide is None or hide != LANG


def fetch(url):
    last = None
    for _ in range(RETRIES):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Porteus-Module-Centre'})
            with urllib.request.urlopen(req, timeout=20) as r:
                return r.read().decode('utf-8', 'ignore')
        except Exception as e:
            last = e
            time.sleep(1)
    raise last


def run_update(parent, key):
    if key in CTX.active_updates:
        show_error(parent, _('Update already running.'))
        return
    if not os.path.isfile(UPDATE_CMD) or not os.access(UPDATE_CMD, os.X_OK):
        show_error(parent, _('Update command not found (/usr/bin/update).'))
        return
    CTX.active_updates.add(key)
    dlg = TermDialog(parent, f"{_('Updating module')} {key}", [UPDATE_CMD, key])
    dlg.connect('destroy', lambda w: CTX.active_updates.discard(key))


class TermDialog(Gtk.Dialog):
    def __init__(self, parent, title, cmd):
        super().__init__(title=title, transient_for=parent)
        self.set_default_size(680, 420)
        self.add_button(_('Close'), Gtk.ResponseType.CLOSE)
        self.connect('response', lambda d, r: d.destroy())
        area = self.get_content_area()
        if HAVE_VTE:
            term = Vte.Terminal()
            term.set_scrollback_lines(-1)
            term.spawn_async(Vte.PtyFlags.DEFAULT, None, cmd, None, GLib.SpawnFlags(0),
                             None, None, -1, None, self._spawn)
            sw = Gtk.ScrolledWindow()
            sw.add(term)
            area.pack_start(sw, True, True, 0)
        else:
            area.pack_start(Gtk.Label(label=_('VTE is not available')), True, True, 0)
        self.show_all()

    def _spawn(self, term, task, *args):
        try:
            term.spawn_async_finish(task)
        except Exception:
            pass


class ListPage(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=6)
        self.tquery = ''
        self._refresh_cb = None
        self.refresh = make_button(icons=('view-refresh-symbolic', 'view-refresh'),
                                   tooltip=_('Refresh list'))
        self.refresh.set_relief(Gtk.ReliefStyle.NONE)
        self.refresh.connect('clicked', lambda b: self._refresh_cb and self._refresh_cb())
        top = hbox()
        top.pack_start(label(f"{_('OS architecture:')} {ARCH}", True), True, True, 0)
        top.pack_end(self.refresh, False, False, 0)
        self.pack_start(top, False, False, 0)
        self.tsearch = Gtk.SearchEntry()
        self.tsearch.set_placeholder_text(_('Search…'))
        self.tsearch.connect('changed', self.on_tsearch)
        self.tsearch.connect('key-press-event', clear_on_escape)
        self.pack_start(self.tsearch, False, False, 0)
        self.store = Gtk.ListStore(str, str, str, str, str, str, str)
        self.filter = self.store.filter_new()
        self.filter.set_visible_func(self._visible)
        self.tree = Gtk.TreeView(model=self.filter, headers_visible=True)
        self.tree.set_tooltip_column(5)
        self.tree.connect('row-activated', lambda *a: self.update_selected())
        rt = Gtk.CellRendererText()
        for title, idx, expand in (('Module', 1, True), ('Category', 2, False),
                                   ('Architecture', 3, False), ('05-devel', 4, False)):
            col = Gtk.TreeViewColumn(_(title), rt, text=idx)
            col.set_resizable(True)
            if expand:
                col.set_expand(True)
                col.set_min_width(180)
                col.set_sort_column_id(idx)
            self.tree.append_column(col)
        sw = Gtk.ScrolledWindow()
        sw.add(self.tree)
        self.pack_start(sw, True, True, 0)
        btn = make_button(_('Update selected'),
                          ('system-software-update-symbolic', 'system-software-update'))
        btn.set_halign(Gtk.Align.END)
        btn.connect('clicked', self.update_selected)
        self.pack_start(btn, False, False, 0)

    def set_rows(self, rows):
        store = self.store
        store.clear()
        req = _('required')
        for key, name, cat, arch, dev, desc in rows:
            dev_disp = req if dev else ''
            tip = f'{name} • {cat} • {arch}'
            if desc:
                tip += f' • {desc}'
            if dev_disp:
                tip += f' • 05-devel: {dev_disp}'
            search = f'{name} {cat} {arch} {dev_disp} {desc}'.lower()
            store.append([key, name, cat, arch, dev_disp, tip, search])

    def _visible(self, model, it, data=None):
        return not self.tquery or self.tquery in model.get_value(it, 6)

    def on_tsearch(self, entry):
        self.tquery = entry.get_text().lower()
        self.filter.refilter()

    def update_selected(self, *args):
        model, it = self.tree.get_selection().get_selected()
        if it is None:
            show_error(self.get_toplevel(), _('Select a module from the list.'))
            return
        run_update(self.get_toplevel(), model.get_value(it, 0))


class UpdatesPage(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=6)
        self.query = ''
        self.cards_pages = []
        self.list_id = '__modules_list__'
        self.nb = None
        self.cancel = threading.Event()
        self.combo = Gtk.ComboBoxText()
        self.entry = Gtk.SearchEntry()
        self.entry.set_hexpand(True)
        self.entry.set_placeholder_text(_('Search…'))
        self.entry.connect('changed', self.on_search)
        self.entry.connect('key-press-event', clear_on_escape)
        bar = hbox()
        bar.pack_start(self.combo, False, False, 0)
        bar.pack_start(self.entry, True, True, 0)
        self.pack_start(bar, False, False, 0)
        self.mid = vbox(0)
        self.pack_start(self.mid, True, True, 0)
        self.spinner = Gtk.Spinner()
        self.status = label('')
        sb = hbox()
        sb.pack_start(self.spinner, False, False, 0)
        sb.pack_start(self.status, True, True, 0)
        self.pack_start(sb, False, False, 0)
        self.list_page = ListPage()
        self.list_page._refresh_cb = self.reload
        self.combo.connect('changed', self.on_combo)
        self.reload()

    def reload(self, *args):
        self.cancel.set()
        self.cancel = threading.Event()
        cancel = self.cancel
        self.spinner.start()
        self.status.set_label(_('Loading list…'))
        def worker():
            mods, error = [], None
            try:
                mods = build_modules(parse_records(fetch(LIST_URL)))
            except Exception as e:
                error = str(e)
            if not cancel.is_set():
                GLib.idle_add(self._apply, mods, error)
        threading.Thread(target=worker, daemon=True).start()

    def _apply(self, mods, error):
        self.spinner.stop()
        self.combo.remove_all()
        self.cards_pages = []
        if self.nb is not None:
            if self.list_page.get_parent() is not None:
                self.nb.remove(self.list_page)
            self.mid.remove(self.nb)
        self.nb = Gtk.Notebook()
        self.nb.connect('switch-page', self.on_page)
        self.mid.pack_start(self.nb, True, True, 0)
        if error:
            self.status.set_label(f"{_('Load error:')} {error}")
            self.nb.show_all()
            return
        by_cat = {}
        for m in mods:
            by_cat.setdefault(m['category'], []).append(m)
        visible_keys = [k for k in CAT_ORDER if k in by_cat and cat_visible(k)]
        scale = CTX.scale()
        for ckey in visible_keys:
            cat_name, cat_icons, _ = CAT_BY_KEY[ckey]
            cmods = [m for m in by_cat[ckey] if arch_ok(m['arch'])]
            if not cmods:
                continue
            fb = Gtk.FlowBox(hexpand=True, valign=Gtk.Align.START, homogeneous=True,
                             row_spacing=8, column_spacing=8)
            fb.set_selection_mode(Gtk.SelectionMode.NONE)
            cards = []
            for m in cmods:
                btn = self._card(m, cat_icons, scale)
                fb.add(btn)
                child = btn.get_parent()
                child.search_text = f"{m['name']} {m['key']}".lower()
                cards.append(child)
            sw = Gtk.ScrolledWindow()
            sw.add(fb)
            disp_name = _(cat_name)
            self.combo.append(ckey, disp_name)
            self.nb.append_page(sw, tab_widget(disp_name, cat_icons))
            self.cards_pages.append(cards)
        # Table rows sorted by (category order, module name)
        rows = []
        cat_ord = {k: i for i, k in enumerate(CAT_ORDER)}
        other_ord = len(CAT_ORDER)
        other_name = _('Other')
        for m in mods:
            if not arch_ok(m['arch']):
                continue
            ck = m['category']
            if ck in CAT_BY_KEY and not cat_visible(ck):
                continue
            if ck in cat_ord:
                cat_disp = _(CAT_BY_KEY[ck][0])
                co = cat_ord[ck]
            else:
                cat_disp = other_name
                co = other_ord
            arch_disp = '32 / 64' if m['arch'] == 'all' else m['arch']
            rows.append((co, m['name'], m['key'], cat_disp, arch_disp, m['dev'], m['desc']))
        rows.sort(key=lambda r: (r[0], r[1]))
        self.list_page.set_rows([(r[2], r[1], r[3], r[4], r[5], r[6]) for r in rows])
        self.combo.append(self.list_id, _('Modules list'))
        self.nb.append_page(self.list_page,
                            tab_widget(_('Modules list'), ('view-list', 'system-software-update')))
        self.nb.show_all()
        if self.combo.get_active() < 0:
            self.combo.set_active(0)
        self.status.set_label(f"{_('Loaded entries:')} {len(mods)} ({_('architecture')} {ARCH})")

    def _card(self, m, cat_icons, scale):
        name, key, devel = m['name'], m['key'], m['dev']
        icons = m['icons'] + cat_icons
        title = Gtk.Label()
        title.set_markup(f'<b>{GLib.markup_escape_text(name)}</b>')
        sub = Gtk.Label(label=key + (' • devel' if devel else ''))
        sub.set_opacity(0.55)
        for l in (title, sub):
            l.set_xalign(0)
            l.set_ellipsize(Pango.EllipsizeMode.END)
        vb = vbox(2)
        vb.set_valign(Gtk.Align.CENTER)
        vb.pack_start(title, False, False, 0)
        vb.pack_start(sub, False, False, 0)
        hb = hbox(8)
        hb.set_margin_start(6)
        hb.set_margin_end(6)
        hb.pack_start(icon(icons, 32), False, False, 0)
        hb.pack_start(vb, True, True, 0)
        btn = Gtk.Button()
        btn.set_size_request(CARD_W * scale, CARD_H * scale)
        btn.set_tooltip_text(name + (' (devel)' if devel else ''))
        btn.connect('clicked', lambda b, k=key: run_update(self.get_toplevel(), k))
        btn.add(hb)
        return btn

    def on_combo(self, combo):
        if self.nb is None:
            return
        idx = combo.get_active()
        if idx >= 0 and idx != self.nb.get_current_page():
            self.nb.set_current_page(idx)

    def on_page(self, nb, page, num):
        if self.combo.get_active() != num:
            self.combo.set_active(num)

    def on_search(self, entry):
        self.query = entry.get_text().lower()
        for cards in self.cards_pages:
            for child in cards:
                child.set_visible(not self.query or self.query in child.search_text)

    def focus_search(self, *args):
        if self.nb is not None:
            cur = self.nb.get_nth_page(self.nb.get_current_page())
            if cur is self.list_page:
                self.list_page.tsearch.grab_focus()
                return True
        self.entry.grab_focus()
        return True

    def refresh_list(self, *args):
        self.reload()
        return True


class LauncherPage(Gtk.Box):
    def __init__(self, title, icons, cmd, desc):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10, border_width=24)
        self.cmd = cmd
        self.set_halign(Gtk.Align.CENTER)
        self.set_valign(Gtk.Align.CENTER)
        d = Gtk.Label(label=desc)
        d.set_line_wrap(True)
        d.set_justify(Gtk.Justification.CENTER)
        d.set_max_width_chars(40)
        btn = make_button(_('Launch'), ('system-run-symbolic', 'system-run'))
        btn.connect('clicked', self.launch)
        box = vbox(8)
        box.set_halign(Gtk.Align.CENTER)
        for w in (icon(icons, 64), Gtk.Label(label=title), d, btn):
            box.pack_start(w, False, False, 0)
        self.pack_start(box, True, True, 0)

    def launch(self, button):
        exe = self.cmd[0]
        if not os.path.isabs(exe):
            exe = shutil.which(exe)
        if not exe or not os.path.isfile(exe) or not os.access(exe, os.X_OK):
            show_error(self.get_toplevel(), _('Command not found'))
            return
        subprocess.Popen([exe, *self.cmd[1:]], stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
                         start_new_session=True)


def add_page(nb, text, icons, widget):
    nb.append_page(widget, tab_widget(text, icons))


def main():
    if not any(os.environ.get(v) for v in ('DISPLAY', 'WAYLAND_DISPLAY')):
        sys.exit(_('Requires X or Wayland session.'))
    if os.geteuid() != 0:
        psu = shutil.which('psu') or '/usr/bin/psu'
        if os.path.isfile(psu) and os.access(psu, os.X_OK):
            os.execvp(psu, [psu, os.path.realpath(__file__), *sys.argv[1:]])
        sys.exit(_('Root privileges required.'))
    GLib.set_prgname('cdr')
    GLib.set_application_name(_('Porteus Module Centre'))
    Gtk.Window.set_default_icon_name('cdr')
    win = Gtk.Window(default_width=960, default_height=660)
    win.set_position(Gtk.WindowPosition.CENTER)
    win.set_icon_name('cdr')
    hb = Gtk.HeaderBar()
    hb.set_show_close_button(True)
    hb.set_title(_('Porteus Module Centre'))
    hb.set_subtitle(_('Manage, update and build Porteus modules'))
    win.set_titlebar(hb)
    updates = UpdatesPage()
    accel = Gtk.AccelGroup()
    win.add_accel_group(accel)
    accel.connect(Gdk.KEY_f, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, updates.focus_search)
    accel.connect(Gdk.KEY_r, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, updates.refresh_list)
    nb = Gtk.Notebook(tab_pos=Gtk.PositionType.LEFT)
    add_page(nb, _('Updates'), ('system-software-update',), updates)
    add_page(nb, _('My Modules'), ('system-file-manager', 'folder'),
             LauncherPage(_('My Modules'), ('system-file-manager', 'folder'),
                          ['/opt/porteus-scripts/lsmodules'], _('Manage your installed modules')))
    add_page(nb, _('Module Maker'), ('package-x-generic', 'application-x-addon'),
             LauncherPage(_('Module Maker'), ('package-x-generic', 'application-x-addon'),
                          ['/opt/porteus-scripts/gtk-slapt-mod'], _('Build modules from packages')))
    add_page(nb, _('Bundles Downloader'), ('download', 'folder-download', 'document-save'),
             LauncherPage(_('Bundles Downloader'), ('download', 'folder-download', 'document-save'),
                          ['/opt/porteus-scripts/xorg/psu', '/opt/porteus-scripts/gtk-bundles'],
                          _('Download pre-built bundles')))
    files_btn = make_button(_('Files'), ('folder-symbolic', 'folder'),
                            tooltip=_('Open /tmp folder'))
    files_btn.connect('clicked', lambda b: subprocess.Popen(
        ['xdg-open', '/tmp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        start_new_session=True))
    action_bar = Gtk.ActionBar()
    action_bar.pack_end(files_btn)
    root = vbox(0)
    root.pack_start(nb, True, True, 0)
    root.pack_start(action_bar, False, False, 0)
    win.add(root)
    win.connect('destroy', Gtk.main_quit)
    win.show_all()
    Gtk.main()


if __name__ == '__main__':
    main()