#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-only

import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk

import subprocess
import threading
import shutil
import os
import stat
import re
import importlib.metadata
from pathlib import Path
from collections import deque

HELPER = "/usr/libexec/almalinux-creative-installer-helper"
RESOLVE_URL = "https://www.blackmagicdesign.com/products/davinciresolve/"

APPS = [
    {"id": "gimp", "name": "GIMP", "type": "dnf", "pkg": "gimp", "emoji": "🖌️"},
    {
        "id": "krita",
        "name": "Krita",
        "type": "flatpak",
        "appid": "org.kde.krita",
        "emoji": "🎨",
    },
    {"id": "blender", "name": "Blender", "type": "dnf", "pkg": "blender", "emoji": "🧊"},
    {
        "id": "discord",
        "name": "Discord",
        "type": "flatpak",
        "appid": "com.discordapp.Discord",
        "emoji": "💬",
    },
    {
        "id": "drawio",
        "name": "draw.io",
        "type": "flatpak",
        "appid": "com.jgraph.drawio.desktop",
        "emoji": "🧭",
    },
    {
        "id": "mattermost",
        "name": "Mattermost",
        "type": "flatpak",
        "appid": "com.mattermost.Desktop",
        "emoji": "🗨️",
    },
    {
        "id": "slack",
        "name": "Slack",
        "type": "flatpak",
        "appid": "com.slack.Slack",
        "emoji": "💼",
    },
    {
        "id": "spotify",
        "name": "Spotify",
        "type": "flatpak",
        "appid": "com.spotify.Client",
        "emoji": "🎵",
    },
    {
        "id": "libreoffice",
        "name": "LibreOffice",
        "type": "flatpak",
        "appid": "org.libreoffice.LibreOffice",
        "emoji": "📄",
    },
    {"id": "resolve", "name": "DaVinci Resolve", "type": "resolve", "emoji": "🎞️"},
]

VERSION_SELECTABLE_APPS = {"gimp", "krita", "blender"}


class AlmaCreativeInstaller(Gtk.Window):
    def __init__(self):
        super().__init__(title="Alma Creative Installer")
        self.set_default_size(980, 640)

        self.repo_widgets = {}
        self.app_widgets = {}

        self._apply_css()

        self.app_version = self._get_app_version()

        hb = Gtk.HeaderBar()
        hb.set_show_close_button(True)
        hb.props.title = "Alma Creative Installer"
        hb.props.subtitle = "Creative & M&E workstation installs • v{}".format(self.app_version)
        self.set_titlebar(hb)

        btn_refresh = Gtk.Button.new_from_icon_name("view-refresh", Gtk.IconSize.BUTTON)
        btn_refresh.set_tooltip_text("Refresh checks")
        btn_refresh.connect("clicked", lambda *_: self.refresh_all_states())
        hb.pack_end(btn_refresh)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        outer.set_border_width(12)
        self.add(outer)

        self.notebook = Gtk.Notebook()
        outer.pack_start(self.notebook, True, True, 0)

        # ---------------- Apps tab ----------------
        apps_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        apps_page.set_border_width(6)
        self.notebook.append_page(apps_page, Gtk.Label(label="Apps"))

        # System Requirements
        req_frame = Gtk.Frame(label="System Requirements")
        req_frame.set_shadow_type(Gtk.ShadowType.IN)
        apps_page.pack_start(req_frame, False, False, 0)

        req_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        req_box.set_border_width(10)
        req_frame.add(req_box)

        req_box.pack_start(
            self._make_requirement_row(
                key="CRB",
                title="CodeReady Builder (CRB) / PowerTools",
                subtitle="Extra dependencies used by many workstation apps.",
                enable_action=["enable_crb"],
            ),
            False, False, 0
        )
        req_box.pack_start(
            self._make_requirement_row(
                key="EPEL",
                title="Extra Packages for Enterprise Linux (EPEL)",
                subtitle="Community packages used by many creative tools.",
                enable_action=["enable_epel"],
            ),
            False, False, 0
        )

        # Applications
        app_frame = Gtk.Frame(label="Applications")
        app_frame.set_shadow_type(Gtk.ShadowType.IN)
        apps_page.pack_start(app_frame, True, True, 0)

        app_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        app_box.set_border_width(10)
        app_frame.add(app_box)

        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self.listbox.get_style_context().add_class("boxed-list")

        app_scroller = Gtk.ScrolledWindow()
        app_scroller.set_hexpand(True)
        app_scroller.set_vexpand(True)
        app_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        app_scroller.add(self.listbox)
        app_box.pack_start(app_scroller, True, True, 0)

        for app in APPS:
            self.listbox.add(self._make_app_row(app))

        actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        apps_page.pack_start(actions, False, False, 0)

        btn_quit = Gtk.Button(label="Quit")
        btn_quit.connect("clicked", lambda *_: self.close())
        actions.pack_end(btn_quit, False, False, 0)

        # ---------------- Logs tab ----------------
        logs_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        logs_page.set_border_width(6)
        self.notebook.append_page(logs_page, Gtk.Label(label="Logs"))

        logs_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        logs_actions.set_halign(Gtk.Align.START)
        logs_page.pack_start(logs_actions, False, False, 0)

        btn_repo_status = Gtk.Button(label="Show enabled repos (debug)")
        btn_repo_status.connect("clicked", lambda *_: self.show_enabled_repos_debug())
        logs_actions.pack_start(btn_repo_status, False, False, 0)

        btn_clear_logs = Gtk.Button(label="Clear logs")
        btn_clear_logs.connect("clicked", lambda *_: self.set_log(""))
        logs_actions.pack_start(btn_clear_logs, False, False, 0)

        self.textview = Gtk.TextView()
        self.textview.set_editable(False)
        self.textview.set_monospace(True)
        self.textbuf = self.textview.get_buffer()

        scroller = Gtk.ScrolledWindow()
        scroller.set_hexpand(True)
        scroller.set_vexpand(True)
        scroller.add(self.textview)
        logs_page.pack_start(scroller, True, True, 0)

        self.append_log("Alma Creative Installer v{}\n".format(self.app_version))
        self.append_log("Ready.\n")

        if not Path(HELPER).exists():
            self.append_log("\nWARNING: Helper not found at {}\n".format(HELPER))

        # IMPORTANT: no pkexec at launch anymore
        self.refresh_all_states()

    # ---------------- CSS ----------------
    def _apply_css(self):
        css = b"""
        .boxed-list row { padding: 10px; }
        .subtle { opacity: 0.75; }
        """
        provider = Gtk.CssProvider()
        provider.load_from_data(css)
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

    def _get_app_version(self):
        try:
            return importlib.metadata.version("almalinux-creative-installer")
        except Exception:
            pass

        try:
            proc = subprocess.run(
                ["rpm", "-q", "--qf", "%{VERSION}-%{RELEASE}", "almalinux-creative-installer"],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            out = proc.stdout.strip()
            if out and "not installed" not in out.lower():
                return out
        except Exception:
            pass

        return "dev"

    # ---------------- UI builders ----------------
    def _make_requirement_row(self, key, title, subtitle, enable_action):
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)

        icon = Gtk.Label(label="…")
        icon.set_width_chars(2)
        row.pack_start(icon, False, False, 0)

        text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        row.pack_start(text_box, True, True, 0)

        title_lbl = Gtk.Label(label=title)
        title_lbl.set_xalign(0)
        title_lbl.set_hexpand(True)
        text_box.pack_start(title_lbl, False, False, 0)

        subtitle_lbl = Gtk.Label(label=subtitle)
        subtitle_lbl.set_xalign(0)
        subtitle_lbl.set_hexpand(True)
        subtitle_lbl.get_style_context().add_class("subtle")
        text_box.pack_start(subtitle_lbl, False, False, 0)

        status_lbl = Gtk.Label(label="Checking…")
        row.pack_start(status_lbl, False, False, 0)

        btn = Gtk.Button(label="Enable")
        btn.connect("clicked", lambda *_: self.run_helper_with_callback(enable_action, on_success=self.refresh_repo_state))
        row.pack_start(btn, False, False, 0)

        self.repo_widgets[key] = {"icon": icon, "status": status_lbl, "button": btn}
        self._set_requirement_state(key, None)
        return row

    def _make_app_row(self, app):
        lbrow = Gtk.ListBoxRow()
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        row.set_border_width(10)
        lbrow.add(row)

        left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        row.pack_start(left, True, True, 0)

        title = Gtk.Label(label="{}  {}".format(app.get("emoji", ""), app["name"]).strip())
        title.set_xalign(0)
        title.set_hexpand(True)
        left.pack_start(title, False, False, 0)

        subtitle = Gtk.Label(label=self._subtitle_for_app(app))
        subtitle.set_xalign(0)
        subtitle.set_hexpand(True)
        subtitle.get_style_context().add_class("subtle")
        left.pack_start(subtitle, False, False, 0)

        status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        spinner = Gtk.Spinner()
        spinner.set_no_show_all(True)
        spinner.hide()
        status_box.pack_start(spinner, False, False, 0)

        status = Gtk.Label(label="…")
        status_box.pack_start(status, False, False, 0)
        row.pack_start(status_box, False, False, 0)

        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        row.pack_start(btn_box, False, False, 0)

        version_combo = None
        if app["id"] in VERSION_SELECTABLE_APPS:
            version_combo = Gtk.ComboBoxText()
            version_combo.append_text("Loading versions…")
            version_combo.set_active(0)
            version_combo.set_sensitive(False)
            btn_box.pack_start(version_combo, False, False, 0)

        install_btn = Gtk.Button(label="Install")
        btn_box.pack_start(install_btn, False, False, 0)

        remove_btn = Gtk.Button(label="Remove")
        btn_box.pack_start(remove_btn, False, False, 0)

        if app["type"] == "dnf":
            install_btn.connect("clicked", lambda *_: self.install_app(app["id"]))
            remove_btn.connect("clicked", lambda *_: self.remove_app(app["id"]))
        elif app["type"] == "flatpak":
            install_btn.connect("clicked", lambda *_: self.install_app(app["id"]))
            remove_btn.connect("clicked", lambda *_: self.confirm_remove_flatpak_app(app["id"]))
        elif app["type"] == "resolve":
            install_btn.connect("clicked", lambda *_: self.install_resolve_flow())
            remove_btn.connect("clicked", lambda *_: self.remove_resolve_flow())

        self.app_widgets[app["id"]] = {
            "app": app,
            "status": status,
            "spinner": spinner,
            "install": install_btn,
            "remove": remove_btn,
            "version_combo": version_combo,
            "version_options": [],
            "busy": False,
        }
        return lbrow

    def _subtitle_for_app(self, app):
        if app["id"] in VERSION_SELECTABLE_APPS:
            if app["type"] == "dnf":
                return "Installed via DNF repositories (version selectable)"
            if app["type"] == "flatpak":
                return "Installed via Flatpak (version/branch selectable)"
        if app["type"] == "dnf":
            return "Installed via DNF repositories"
        if app["type"] == "flatpak":
            return "Installed via Flatpak (recommended upstream for EL)"
        if app["type"] == "resolve":
            return "Guided install: deps → open download page → pick .run/.rpm"
        return ""

    # ---------------- Busy UI helpers ----------------
    def set_app_busy(self, app_id, busy, msg=None):
        w = self.app_widgets.get(app_id)
        if not w:
            return
        w["busy"] = busy

        if busy:
            w["spinner"].show()
            w["spinner"].start()
            if msg:
                w["status"].set_text(msg)
            w["install"].set_sensitive(False)
            w["remove"].set_sensitive(False)
            if w.get("version_combo") is not None:
                w["version_combo"].set_sensitive(False)
        else:
            w["spinner"].stop()
            w["spinner"].hide()
            if w.get("version_combo") is not None:
                w["version_combo"].set_sensitive(True)

    # ---------------- Log helpers ----------------
    def set_log(self, text):
        self.textbuf.set_text(text)

    def append_log(self, text):
        end = self.textbuf.get_end_iter()
        self.textbuf.insert(end, text)
        mark = self.textbuf.create_mark(None, self.textbuf.get_end_iter(), True)
        self.textview.scroll_mark_onscreen(mark)

    # ---------------- State refresh ----------------
    def refresh_all_states(self):
        self.refresh_repo_state()
        self.refresh_app_versions()
        for app in APPS:
            self.refresh_app_state(app["id"])

    # ---------------- Version catalogs (unprivileged) ----------------
    def refresh_app_versions(self):
        for app in APPS:
            if app["id"] in VERSION_SELECTABLE_APPS:
                self._refresh_single_app_versions(app)

    def _refresh_single_app_versions(self, app):
        app_id = app["id"]
        w = self.app_widgets.get(app_id)
        if not w:
            return

        combo = w.get("version_combo")
        if combo is None:
            return

        combo.remove_all()
        combo.append_text("Loading versions…")
        combo.set_active(0)
        combo.set_sensitive(False)

        def worker():
            if app["type"] == "dnf":
                options = self._fetch_dnf_versions(app["pkg"])
            elif app["type"] == "flatpak":
                options = self._fetch_flatpak_versions(app["appid"])
            else:
                options = []
            GLib.idle_add(self._set_app_version_options, app_id, options)

        threading.Thread(target=worker, daemon=True).start()

    def _fetch_dnf_versions(self, pkg):
        options = [{"label": "Latest available", "mode": "latest"}]
        try:
            proc = subprocess.run(
                ["dnf", "-q", "--showduplicates", "list", pkg],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            seen = set()
            for line in proc.stdout.splitlines():
                stripped = line.strip()
                if not stripped:
                    continue
                parts = stripped.split()
                if len(parts) < 2:
                    continue
                name_arch = parts[0]
                version = parts[1]
                if not name_arch.startswith(pkg + "."):
                    continue
                if version in seen:
                    continue
                seen.add(version)
                options.append(
                    {
                        "label": version,
                        "mode": "specific",
                        "install_arg": "{}-{}".format(pkg, version),
                    }
                )
        except Exception:
            pass
        return options

    def _fetch_flatpak_versions(self, appid):
        stable_version = None
        branch_options = []
        if not shutil.which("flatpak"):
            return [{"label": "Latest stable", "branch": "stable"}]

        try:
            proc = subprocess.run(
                [
                    "flatpak",
                    "remote-ls",
                    "--all",
                    "--app",
                    "--columns=application,branch,version",
                    "flathub",
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            seen = set()
            for line in proc.stdout.splitlines():
                stripped = line.strip()
                if not stripped:
                    continue
                parts = stripped.split(None, 2)
                if len(parts) < 2:
                    continue
                app_col = parts[0]
                branch = parts[1]
                version = parts[2].strip() if len(parts) > 2 else ""
                if app_col != appid or branch in seen:
                    continue
                seen.add(branch)
                if branch == "stable":
                    stable_version = version or stable_version
                    continue
                if version:
                    label = "{} ({})".format(version, branch)
                else:
                    label = branch
                branch_options.append({"label": label, "branch": branch})
        except Exception:
            pass

        if stable_version:
            options = [{"label": "Latest stable ({})".format(stable_version), "branch": "stable"}]
        else:
            options = [{"label": "Latest stable", "branch": "stable"}]

        # Group as: latest stable, then older version numbers (no noisy duplicate commit labels)
        seen_versions = {stable_version} if stable_version else set()
        for entry in self._fetch_flatpak_commit_history(appid, "stable"):
            version = entry.get("version")
            if not version or version in seen_versions:
                continue
            seen_versions.add(version)
            options.append(
                {
                    "label": version,
                    "branch": entry.get("branch", "stable"),
                    "commit": entry.get("commit"),
                }
            )

        options.extend(branch_options)
        return options

    def _fetch_flatpak_commit_history(self, appid, branch, limit=8):
        entries = []
        try:
            proc = subprocess.run(
                ["flatpak", "remote-info", "--log", "flathub", "{}//{}".format(appid, branch)],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            current_commit = None
            current_subject = ""

            for line in proc.stdout.splitlines():
                stripped = line.strip()
                if stripped.startswith("Commit:"):
                    current_commit = stripped.split(":", 1)[1].strip()
                    current_subject = ""
                elif stripped.startswith("Subject:") and current_commit:
                    current_subject = stripped.split(":", 1)[1].strip()
                    version = self._extract_version_from_text(current_subject)
                    entries.append(
                        {
                            "branch": branch,
                            "commit": current_commit,
                            "version": version,
                        }
                    )
                    current_commit = None

            # Deduplicate while preserving order and cap list length
            unique = []
            seen = set()
            for e in entries:
                key = (e.get("branch"), e.get("commit"))
                if key in seen:
                    continue
                seen.add(key)
                unique.append(e)
                if len(unique) >= limit:
                    break
            return unique
        except Exception:
            return []

    def _extract_version_from_text(self, text):
        if not text:
            return None
        # Capture version-like tokens such as 5.2.4 or 26.2.0.3
        matches = re.findall(r"\b\d+(?:\.\d+){1,4}\b", text)
        return matches[-1] if matches else None

    def _set_app_version_options(self, app_id, options):
        w = self.app_widgets.get(app_id)
        if not w:
            return

        combo = w.get("version_combo")
        if combo is None:
            return

        w["version_options"] = options[:]

        combo.remove_all()
        for option in options:
            combo.append_text(option.get("label", "unknown"))
        combo.set_active(0)
        combo.set_sensitive(True)

    def _get_selected_version_option(self, app_id):
        w = self.app_widgets.get(app_id)
        if not w:
            return None
        combo = w.get("version_combo")
        options = w.get("version_options", [])
        if combo is None or not options:
            return None
        idx = combo.get_active()
        if idx < 0 or idx >= len(options):
            return options[0]
        return options[idx]

    # ---------------- Repo probing (UNPRIVILEGED) ----------------
    def _dnf_enabled_repo_ids(self):
        """
        Returns a set of enabled repo IDs by parsing:
          dnf -q repolist --enabled
        No root needed.
        """
        try:
            proc = subprocess.run(
                ["dnf", "-q", "repolist", "--enabled"],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            out = proc.stdout.splitlines()
        except Exception:
            return set()

        repos = set()
        for ln in out:
            ln = ln.strip()
            if not ln:
                continue
            low = ln.lower()
            if low.startswith("repo id") or low.startswith("repolist:"):
                continue
            # First column is repo id
            parts = ln.split()
            if parts:
                repos.add(parts[0].strip())
        return repos

    def _detect_crb(self, repos):
        # Alma 9/10: crb; older naming: powertools
        for r in repos:
            if r == "crb" or r.startswith("crb"):
                return True
        for r in repos:
            if r == "powertools" or r.startswith("powertools"):
                return True
        return False

    def _detect_epel(self, repos):
        for r in repos:
            if r == "epel" or r.startswith("epel"):
                return True
        return False

    def _set_requirement_state(self, key, state):
        w = self.repo_widgets.get(key)
        if not w:
            return
        if state is None:
            w["icon"].set_text("…")
            w["status"].set_text("Checking…")
            w["button"].set_sensitive(False)
        elif state is True:
            w["icon"].set_text("✅")
            w["status"].set_text("Enabled")
            w["button"].set_sensitive(False)
        else:
            w["icon"].set_text("❌")
            w["status"].set_text("Not enabled")
            w["button"].set_sensitive(True)

    def refresh_repo_state(self):
        self._set_requirement_state("CRB", None)
        self._set_requirement_state("EPEL", None)

        def worker():
            repos = self._dnf_enabled_repo_ids()
            crb = self._detect_crb(repos)
            epel = self._detect_epel(repos)

            GLib.idle_add(self._set_requirement_state, "CRB", crb)
            GLib.idle_add(self._set_requirement_state, "EPEL", epel)
            GLib.idle_add(self.append_log, "✅ Requirements refreshed\n")

        threading.Thread(target=worker, daemon=True).start()

    def show_enabled_repos_debug(self):
        def worker():
            try:
                proc = subprocess.run(
                    ["dnf", "repolist", "--enabled"],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    check=False,
                )
                GLib.idle_add(self.append_log, "\n$ dnf repolist --enabled\n")
                GLib.idle_add(self.append_log, proc.stdout + "\n")
            except Exception as e:
                GLib.idle_add(self.append_log, "❌ ERROR: {}\n".format(e))

        threading.Thread(target=worker, daemon=True).start()

    # ---------------- App status (no pkexec) ----------------
    def _flatpak_install_scope(self, appid):
        if not shutil.which("flatpak"):
            return None
        if subprocess.run(
            ["flatpak", "info", "--system", appid],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL
        ).returncode == 0:
            return "system"
        if subprocess.run(
            ["flatpak", "info", "--user", appid],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL
        ).returncode == 0:
            return "user"
        return None

    def _prompt_flatpak_scope(self, app_name):
        user_scope_available = self._flatpak_user_scope_available()

        dialog = Gtk.MessageDialog(
            parent=self,
            flags=0,
            message_type=Gtk.MessageType.QUESTION,
            buttons=Gtk.ButtonsType.NONE,
            text="Install {} via Flatpak".format(app_name),
        )
        dialog.format_secondary_text(
            "Choose where to install this Flatpak app.\n\n"
            "System-wide installs are available to all users.\n"
            "User installs apply only to your account."
        )
        user_btn = dialog.add_button("User only", 1)
        dialog.add_button("System-wide", 2)
        dialog.add_button("Cancel", 0)
        dialog.set_default_response(2)

        if not user_scope_available:
            user_btn.set_sensitive(False)
            dialog.format_secondary_text(
                "Choose where to install this Flatpak app.\n\n"
                "System-wide installs are available to all users.\n"
                "User installs apply only to your account.\n\n"
                "User-only mode is unavailable because a user Flatpak installation "
                "was not detected."
            )

        response = dialog.run()
        dialog.destroy()

        if response == 1:
            return "user"
        if response == 2:
            return "system"
        return None

    def _flatpak_user_scope_available(self):
        if not shutil.which("flatpak"):
            return False
        # Consider user-scope available only when the per-user repo is initialized.
        # This keeps "User only" greyed out unless user flatpak mode is already set up.
        user_repo_dir = Path.home() / ".local/share/flatpak/repo"
        return user_repo_dir.is_dir()

    def refresh_app_state(self, app_id):
        w = self.app_widgets.get(app_id)
        if not w:
            return
        app = w["app"]

        if app["type"] == "resolve":
            installed = self._resolve_installed()
            if installed:
                w["status"].set_text("✅ Installed")
                w["install"].set_sensitive(False)
                w["remove"].set_sensitive(True)
            else:
                w["status"].set_text("ℹ️ Guided install")
                w["install"].set_sensitive(True)
                w["remove"].set_sensitive(False)
            return

        if app["type"] == "dnf":
            installed = subprocess.run(
                ["rpm", "-q", app["pkg"]],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL
            ).returncode == 0

        elif app["type"] == "flatpak":
            if not shutil.which("flatpak"):
                w["status"].set_text("ℹ️ Flatpak not installed")
                w["install"].set_sensitive(True)
                w["remove"].set_sensitive(False)
                return
            scope = self._flatpak_install_scope(app["appid"])
            installed = scope is not None
        else:
            installed = False

        if installed:
            if app["type"] == "flatpak" and scope == "user":
                w["status"].set_text("✅ Installed (user)")
                w["install"].set_sensitive(False)
                w["remove"].set_sensitive(True)
            else:
                w["status"].set_text("✅ Installed")
                w["install"].set_sensitive(False)
                w["remove"].set_sensitive(True)
        else:
            w["status"].set_text("❌ Not installed")
            w["install"].set_sensitive(True)
            w["remove"].set_sensitive(False)

    # ---------------- Install/remove actions with spinner ----------------
    def install_app(self, app_id):
        w = self.app_widgets.get(app_id)
        if not w or w.get("busy"):
            return

        app = w["app"]
        if app["type"] == "dnf":
            selected = self._get_selected_version_option(app_id)
            if selected and selected.get("mode") == "specific":
                args = ["install_pkg_version", selected.get("install_arg", app["pkg"])]
            else:
                args = ["install_pkg", app["pkg"]]
        elif app["type"] == "flatpak":
            scope = self._flatpak_install_scope(app["appid"])
            if scope is None:
                scope = self._prompt_flatpak_scope(app["name"])
                if scope is None:
                    return
            selected = self._get_selected_version_option(app_id)
            branch = (selected or {}).get("branch", "stable")
            commit = (selected or {}).get("commit")

            if scope == "user":
                if commit:
                    args = ["install_flatpak_app_user_commit", app["appid"], branch, commit]
                else:
                    args = ["install_flatpak_app_user_branch", app["appid"], branch]
            else:
                if commit:
                    args = ["install_flatpak_app_commit", app["appid"], branch, commit]
                else:
                    args = ["install_flatpak_app_branch", app["appid"], branch]
        else:
            return

        self.set_app_busy(app_id, True, "Installing…")
        self.run_helper_with_callback(args, on_success=lambda: self._finish_app_action(app_id))

    def remove_app(self, app_id):
        w = self.app_widgets.get(app_id)
        if not w or w.get("busy"):
            return

        app = w["app"]
        if app["type"] == "dnf":
            args = ["remove_pkg", app["pkg"]]
        else:
            return

        self.set_app_busy(app_id, True, "Removing…")
        self.run_helper_with_callback(args, on_success=lambda: self._finish_app_action(app_id))

    def _finish_app_action(self, app_id):
        self.set_app_busy(app_id, False)
        self.refresh_app_state(app_id)

    # ---------------- Flatpak removal prompt (with spinner) ----------------
    def confirm_remove_flatpak_app(self, app_id):
        w = self.app_widgets.get(app_id)
        if w and w.get("busy"):
            return
        if not w:
            return

        app = w["app"]
        appid = app.get("appid")
        if not appid:
            return

        app_name = app.get("name", appid)
        scope = self._flatpak_install_scope(appid)

        if scope == "user":
            dialog = Gtk.MessageDialog(
                parent=self,
                flags=0,
                message_type=Gtk.MessageType.WARNING,
                buttons=Gtk.ButtonsType.NONE,
                text="Remove {} (user install)?".format(app_name),
            )
            dialog.format_secondary_text(
                "This will remove the user-installed Flatpak app for your account."
            )
            dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
            dialog.add_button("Remove {}".format(app_name), Gtk.ResponseType.OK)

            resp = dialog.run()
            dialog.destroy()

            if resp == Gtk.ResponseType.OK:
                self.set_app_busy(app_id, True, "Removing…")

                def after_remove_user():
                    self.set_app_busy(app_id, False)
                    self.refresh_app_state(app_id)

                self.run_helper_with_callback(
                    ["remove_flatpak_app_user", appid],
                    on_success=after_remove_user
                )
            return

        if scope is None:
            self.append_log("ℹ️ {} is not installed.\n".format(app_name))
            self.refresh_app_state(app_id)
            return

        dialog = Gtk.MessageDialog(
            parent=self,
            flags=0,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text="Remove {}?".format(app_name),
        )
        dialog.format_secondary_text("This will remove {}.".format(app_name))
        dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
        dialog.add_button("Remove {}".format(app_name), Gtk.ResponseType.OK)

        resp = dialog.run()
        dialog.destroy()

        if resp == Gtk.ResponseType.OK:
            self.set_app_busy(app_id, True, "Removing…")

            def after_remove():
                self.set_app_busy(app_id, False)
                self.refresh_app_state(app_id)

            self.run_helper_with_callback(["remove_flatpak_app", appid], on_success=after_remove)

    # ---------------- Helper runner (PRIVILEGED) ----------------
    def run_helper_with_callback(self, args, on_success=None):
        if not Path(HELPER).exists():
            self.append_log("❌ ERROR: Helper not found: " + HELPER + "\n")
            return

        cmd = ["pkexec", HELPER] + args
        self.append_log("\n$ " + " ".join(cmd) + "\n")

        def worker():
            last_lines = deque(maxlen=40)

            def summarize_failure(rc):
                for line in reversed(last_lines):
                    s = line.strip()
                    if s:
                        return s
                return "Exit code {}".format(rc)

            try:
                proc = subprocess.Popen(
                    cmd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    bufsize=1,
                )
                assert proc.stdout is not None

                for line in proc.stdout:
                    last_lines.append(line)
                    GLib.idle_add(self.append_log, line)

                rc = proc.wait()

                if rc == 0:
                    GLib.idle_add(self.append_log, "✅ Successful\n")
                    GLib.idle_add(self.append_log, "[exit code: {}]\n".format(rc))
                    if on_success:
                        GLib.idle_add(on_success)
                else:
                    reason = summarize_failure(rc)
                    GLib.idle_add(self.append_log, "❌ Failed: {}\n".format(reason))
                    GLib.idle_add(self.append_log, "[exit code: {}]\n".format(rc))
                    GLib.idle_add(self._stop_all_busy)

            except Exception as e:
                GLib.idle_add(self.append_log, "❌ ERROR: {}\n".format(e))
                GLib.idle_add(self._stop_all_busy)

        threading.Thread(target=worker, daemon=True).start()

    def _stop_all_busy(self):
        for app_id in list(self.app_widgets.keys()):
            w = self.app_widgets[app_id]
            if w.get("busy"):
                self.set_app_busy(app_id, False)
                self.refresh_app_state(app_id)

    # ---------------- DaVinci Resolve flow ----------------
    def install_resolve_flow(self):
        self._ensure_selinux_for_resolve()

    def _selinux_getenforce(self):
        try:
            proc = subprocess.run(
                ["getenforce"],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            out = proc.stdout.strip().lower()
            if out:
                return out
        except Exception:
            pass
        return "unknown"

    def _ensure_selinux_for_resolve(self):
        mode = self._selinux_getenforce()
        if mode in ("permissive", "disabled"):
            self.run_helper_with_callback(["prepare_resolve_deps"], on_success=self._resolve_open_and_pick)
            return

        dialog = Gtk.MessageDialog(
            parent=self,
            flags=0,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text="DaVinci Resolve requires SELinux permissive or disabled.",
        )
        dialog.format_secondary_text(
            "Choose how you want to proceed before installing Resolve.\n\n"
            "A reboot is required for the SELinux change to be fully applied "
            "before launching DaVinci Resolve."
        )
        dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
        dialog.add_button("Permissive (permanent)", Gtk.ResponseType.APPLY)
        dialog.add_button("Disable SELinux (permanent)", Gtk.ResponseType.YES)

        resp = dialog.run()
        dialog.destroy()

        if resp == Gtk.ResponseType.CANCEL:
            return
        if resp == Gtk.ResponseType.APPLY:
            mode_arg = "permissive"
        else:
            mode_arg = "disabled"

        self.run_helper_with_callback(
            ["set_selinux_mode", mode_arg],
            on_success=lambda: self.run_helper_with_callback(
                ["prepare_resolve_deps"],
                on_success=self._resolve_open_and_pick
            )
        )

    def _resolve_open_and_pick(self):
        if shutil.which("xdg-open"):
            subprocess.Popen(["xdg-open", RESOLVE_URL])
            self.append_log("Opened download page: {}\n".format(RESOLVE_URL))
        else:
            self.append_log("Please open this URL manually: {}\n".format(RESOLVE_URL))

        self.pick_resolve_installer()

    def pick_resolve_installer(self):
        dialog = Gtk.FileChooserDialog(
            title="Select DaVinci Resolve installer (.run preferred)",
            parent=self,
            action=Gtk.FileChooserAction.OPEN,
        )
        dialog.add_buttons(
            Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
            Gtk.STOCK_OPEN, Gtk.ResponseType.OK,
        )

        f = Gtk.FileFilter()
        f.set_name("Installers (*.run, *.rpm)")
        f.add_pattern("DaVinci_Resolve*.run")
        f.add_pattern("DaVinci_Resolve*.rpm")
        f.add_pattern("*.run")
        f.add_pattern("*.rpm")
        dialog.add_filter(f)

        resp = dialog.run()
        if resp == Gtk.ResponseType.OK:
            path = dialog.get_filename()
            if path:
                if path.endswith(".run"):
                    self.run_local_run_installer(path)
                else:
                    self.run_helper_with_callback(["install_local_file", path],
                                                  on_success=lambda: self.refresh_app_state("resolve"))
        dialog.destroy()

    def _resolve_installed(self):
        candidates = [
            "/opt/resolve/bin/resolve",
            "/opt/resolve/bin/resolve.sh",
            "/opt/resolve/installer",
            "/usr/bin/resolve",
            "/usr/local/bin/resolve",
        ]
        for c in candidates:
            if Path(c).exists():
                return True
        return shutil.which("resolve") is not None

    def remove_resolve_flow(self):
        if not self._resolve_installed():
            self.append_log("ℹ️ DaVinci Resolve does not appear to be installed.\n")
            self.refresh_app_state("resolve")
            return

        dialog = Gtk.MessageDialog(
            parent=self,
            flags=0,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text="Uninstall DaVinci Resolve?",
        )
        dialog.format_secondary_text(
            "This will run the vendor uninstaller.\n\n"
            "Note: The uninstaller must run as a normal user (not root)."
        )
        dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
        dialog.add_button("Uninstall", Gtk.ResponseType.OK)

        resp = dialog.run()
        dialog.destroy()

        if resp == Gtk.ResponseType.OK:
            self.run_resolve_uninstaller()

    def run_resolve_uninstaller(self):
        path = "/opt/resolve/installer"
        if not Path(path).exists():
            self.append_log("❌ ERROR: Resolve uninstaller not found at {}\n".format(path))
            return

        try:
            st = os.stat(path)
            if not (st.st_mode & stat.S_IXUSR):
                os.chmod(path, st.st_mode | stat.S_IXUSR)
        except Exception as e:
            self.append_log("❌ ERROR: could not make uninstaller executable: {}\n".format(e))
            return

        cmd = [path]
        self.append_log("\n$ " + " ".join(cmd) + "\n")

        def worker():
            last_lines = deque(maxlen=40)

            def summarize_failure(rc):
                for line in reversed(last_lines):
                    s = line.strip()
                    if s:
                        return s
                return "Exit code {}".format(rc)

            try:
                proc = subprocess.Popen(
                    cmd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    bufsize=1,
                )
                assert proc.stdout is not None

                for line in proc.stdout:
                    last_lines.append(line)
                    GLib.idle_add(self.append_log, line)

                rc = proc.wait()

                if rc == 0:
                    GLib.idle_add(self.append_log, "✅ Successful\n")
                    GLib.idle_add(self.append_log, "[exit code: {}]\n".format(rc))
                    GLib.idle_add(self.refresh_app_state, "resolve")
                    GLib.idle_add(self._prompt_reenable_selinux)
                else:
                    reason = summarize_failure(rc)
                    GLib.idle_add(self.append_log, "❌ Failed: {}\n".format(reason))
                    GLib.idle_add(self.append_log, "[exit code: {}]\n".format(rc))
            except Exception as e:
                GLib.idle_add(self.append_log, "❌ ERROR: {}\n".format(e))

        threading.Thread(target=worker, daemon=True).start()

    def _prompt_reenable_selinux(self):
        mode = self._selinux_getenforce()
        if mode == "enforcing":
            return

        dialog = Gtk.MessageDialog(
            parent=self,
            flags=0,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text="Re-enable SELinux enforcing?",
        )
        dialog.format_secondary_text(
            "DaVinci Resolve has been removed. You can restore SELinux enforcing.\n\n"
            "A reboot may be required for the change to fully apply."
        )
        dialog.add_button("Leave as-is", Gtk.ResponseType.CANCEL)
        dialog.add_button("Set Enforcing (permanent)", Gtk.ResponseType.OK)

        resp = dialog.run()
        dialog.destroy()

        if resp == Gtk.ResponseType.OK:
            self.run_helper_with_callback(["set_selinux_mode", "enforcing"])

    def run_local_run_installer(self, path):
        if not path or not Path(path).exists():
            self.append_log("❌ ERROR: installer path missing or not found.\n")
            return

        try:
            st = os.stat(path)
            if not (st.st_mode & stat.S_IXUSR):
                os.chmod(path, st.st_mode | stat.S_IXUSR)
        except Exception as e:
            self.append_log("❌ ERROR: could not make installer executable: {}\n".format(e))
            return

        cmd = [path]
        self.append_log("\n$ SKIP_PACKAGE_CHECK=1 " + " ".join(cmd) + "\n")

        def worker():
            last_lines = deque(maxlen=40)

            def summarize_failure(rc):
                for line in reversed(last_lines):
                    s = line.strip()
                    if s:
                        return s
                return "Exit code {}".format(rc)

            try:
                env = os.environ.copy()
                env["SKIP_PACKAGE_CHECK"] = "1"
                proc = subprocess.Popen(
                    cmd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    bufsize=1,
                    env=env,
                )
                assert proc.stdout is not None

                for line in proc.stdout:
                    last_lines.append(line)
                    GLib.idle_add(self.append_log, line)

                rc = proc.wait()

                if rc == 0:
                    GLib.idle_add(self.append_log, "✅ Successful\n")
                    GLib.idle_add(self.append_log, "[exit code: {}]\n".format(rc))
                    GLib.idle_add(self.refresh_app_state, "resolve")
                else:
                    reason = summarize_failure(rc)
                    GLib.idle_add(self.append_log, "❌ Failed: {}\n".format(reason))
                    GLib.idle_add(self.append_log, "[exit code: {}]\n".format(rc))
            except Exception as e:
                GLib.idle_add(self.append_log, "❌ ERROR: {}\n".format(e))

        threading.Thread(target=worker, daemon=True).start()


def main():
    ok, _argv = Gtk.init_check()
    if not ok:
        print("ERROR: GTK could not be initialized.")
        print("Run inside a GNOME/KDE graphical session as a normal user (not root over SSH).")
        return 1

    win = AlmaCreativeInstaller()
    win.connect("destroy", Gtk.main_quit)
    win.show_all()
    Gtk.main()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
