#!/usr/bin/env python3
#--------------------------------------------------------------------------------------------------------
# Name: Linux Lite - Lite Core
# Architecture: amd64
# Author: Jerry Bezencon
# Website: https://www.linuxliteos.com
# Language: Python/GTK4
# Licence: GPLv2
#--------------------------------------------------------------------------------------------------------

import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, GLib

import subprocess
import os
import sys
import threading

import gettext as _gt, locale as _loc
TEXTDOMAIN = "lite-core"
ENV_FILE = "/tmp/.lite-core-env"
# When re-launched as root via pkexec, LANG/LANGUAGE are scrubbed; main() saves
# them to ENV_FILE before elevating and we restore them here, before gettext
# binds and before REMOVABLE_PACKAGES' _() calls run.
if os.geteuid() == 0 and os.path.exists(ENV_FILE):
    try:
        with open(ENV_FILE) as _ef:
            for _line in _ef:
                _line = _line.strip()
                if "=" in _line:
                    _k, _v = _line.split("=", 1)
                    if _k in ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_NUMERIC"):
                        os.environ[_k] = _v
    except Exception:
        pass
try:
    _loc.setlocale(_loc.LC_ALL, "")
except _loc.Error:
    pass
_gt.bindtextdomain(TEXTDOMAIN, "/usr/share/locale")
_gt.textdomain(TEXTDOMAIN)
_ = _gt.translation(TEXTDOMAIN, "/usr/share/locale", fallback=True).gettext

APP_ID = "com.linuxlite.core"
APP_NAME = "Lite Core"
ICON_NAME = "lite-core"

# Hidden packages — removed silently during cleanup (glob patterns supported)
HIDDEN_PACKAGES = [
    "default-jre-headless",
    "libobasis*",
    "openjdk*",
]

# Desktop files in ~/.local/share/applications/ to remove per app
DESKTOP_FILE_CLEANUP = {
    "Blueman": ["blueman-manager.desktop"],
    "Evince": ["evince.desktop", "org.gnome.Evince.desktop"],
    "GNU Info": ["info.desktop"],
    "GNOME Disks": ["org.gnome.DiskUtility.desktop"],
    "GNOME Paint": ["gnome-paint.desktop"],
    "GNOME System Log": ["gnome-system-log.desktop"],
    "GParted": ["gparted.desktop"],
    "LibreOffice": ["libreoffice*.desktop"],
    "LightDM Settings": ["lightdm-gtk-greeter-settings.desktop"],
    "Onboard": ["onboard-settings.desktop"],
    "Orca": ["orca-settingsll.desktop"],
    "Shotwell": [
        "shotwell.desktop",
        "org.gnome.Shotwell.desktop",
        "org.gnome.Shotwell-Profile-Browser.desktop",
        "org.gnome.Shotwell-Viewer.desktop",
    ],
    "Simple Scan": ["simple-scan.desktop"],
    "Timeshift": ["timeshift-gtk.desktop"],
    "Xfburn": ["xfburn.desktop"],
}

# Packages to remove — each entry: (display_name, icon_name, package_names, description)
REMOVABLE_PACKAGES = [
    ("Blueman", "blueman", "blueman", _("Bluetooth manager")),
    ("Deja Dup", "deja-dup", "deja-dup", _("Backup tool")),
    ("Evince", "evince", "evince", _("Document viewer")),
    ("GIMP", "gimp", "gimp gimp-data", _("GNU Image Manipulation Program")),
    ("GNOME Disks", "gnome-disks", "gnome-disk-utility", _("Disk management utility")),
    ("GNOME Font Viewer", "org.gnome.font-viewer", "gnome-font-viewer", _("Font viewer")),
    ("GNOME Paint", "gnome-paint", "gnome-paint", _("Simple drawing application")),
    ("GNU Info", "dialog-information", "info", _("GNU info document viewer")),
    ("GNOME System Log", "utilities-log-viewer", "gnome-system-log", _("System log viewer")),
    ("GParted", "gparted", "gparted", _("Partition editor")),
    ("Hardinfo2", "hardinfo2", "hardinfo2", _("System information tool")),
    ("LibreOffice", "libreoffice-startcenter",
     "libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw "
     "libreoffice-math libreoffice-base libreoffice-common libreoffice-core",
     _("Office productivity suite")),
    ("LightDM Settings", "lightdm-gtk-greeter-settings",
     "lightdm-gtk-greeter-settings lightdm-settings", _("Login screen settings")),
    ("Mintstick", "mintstick", "mintstick", _("USB image writer and formatter")),
    ("Mousepad", "mousepad", "mousepad", _("Text editor")),
    ("Onboard", "onboard", "onboard", _("On-screen keyboard")),
    ("Orca", "orca", "orca", _("Screen reader")),
    # NOTE: Samba is intentionally NOT offered here. The lite-software package
    # (which ships Lite Core itself, plus Lite Share Folder etc.) Depends on
    # samba, so `apt-get purge -y samba` would drag the whole Lite suite out
    # with it. There is no safe way to remove it from this tool.
    ("Shotwell", "shotwell", "shotwell", _("Photo manager")),
    ("Simple Scan", "org.gnome.SimpleScan", "simple-scan", _("Document scanner")),
    ("Thunderbird", "thunderbird", "thunderbird", _("Email client")),
    ("Timeshift", "timeshift", "timeshift", _("System backup and restore")),
    ("VLC", "vlc", "vlc vlc-data vlc-plugin-base", _("Media player")),
    ("Xfburn", "xfburn", "xfburn", _("CD/DVD burning tool")),
    ("Xfce4 Screenshooter", "xfce4-screenshooter", "xfce4-screenshooter", _("Screenshot tool")),
]


def get_package_status(packages):
    """Check if the main package is installed."""
    pkg = packages.split()[0]
    try:
        result = subprocess.run(
            ["dpkg-query", "-W", "-f=${Status}", pkg],
            capture_output=True, text=True
        )
        if result.returncode == 0 and "install ok installed" in result.stdout:
            return True
    except FileNotFoundError:
        pass
    return False


# Packages that must never be removed as collateral damage. Removing any of
# these would cripple the Lite suite — Lite Core itself ships in lite-software,
# which Depends on samba, so a naive `purge` could otherwise take the whole
# management stack out (this exact thing happened: purging Samba removed
# lite-software entirely). Before purging anything we simulate the transaction
# and bail if a protected package would be dragged along.
PROTECTED_EXACT = {"lite-software"}
PROTECTED_PREFIXES = ("lite-", "linuxlite")


def purge_would_remove_protected(pkg_list):
    """Dry-run `apt-get purge` for pkg_list; return the protected packages apt
    would ALSO remove (empty list means the purge is safe). Forces C locale so
    the simulation output is parsed in English regardless of UI language."""
    try:
        sim = subprocess.run(
            ["apt-get", "-s", "purge"] + pkg_list,
            capture_output=True, text=True, timeout=120,
            env={**os.environ, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"},
        )
    except Exception:
        # If we can't simulate, treat it as unsafe rather than risk a blind
        # destructive purge.
        return ["<simulation-failed>"]
    hits = []
    for line in sim.stdout.splitlines():
        line = line.strip()
        if line.startswith("Remv "):
            name = line.split()[1]
            if name in PROTECTED_EXACT or name.startswith(PROTECTED_PREFIXES):
                hits.append(name)
    return hits


class LiteCoreWindow(Gtk.ApplicationWindow):
    """Main application window."""

    def __init__(self, app):
        super().__init__(application=app)
        self.set_title(APP_NAME)
        self.set_default_size(520, 600)
        self.set_resizable(False)
        self.set_icon_name(ICON_NAME)

        # Track running operation
        self._running = False

        # Header bar — plain Gtk.HeaderBar so the window adopts the Linux-Lite
        # GTK theme: dark titlebar in Light mode and full Linux-Lite <->
        # Linux-Dark following. libadwaita ignored the GTK theme entirely.
        header = Gtk.HeaderBar()
        header.set_title_widget(Gtk.Label(label=APP_NAME))
        self.set_titlebar(header)

        # Scrollable content area
        scrolled = Gtk.ScrolledWindow()
        scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scrolled.set_vexpand(True)
        self.set_child(scrolled)

        content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16,
                              margin_top=20, margin_bottom=20,
                              margin_start=20, margin_end=20)
        scrolled.set_child(content_box)

        # Description
        desc = Gtk.Label(
            label=_("Strip your Linux Lite installation down to the essentials.\n"
                  "Select the applications you want to remove.")
        )
        desc.set_wrap(True)
        desc.set_xalign(0.5)
        desc.set_justify(Gtk.Justification.CENTER)
        content_box.append(desc)

        # Group heading
        group_title = Gtk.Label(label=_("Applications"))
        group_title.set_xalign(0)
        group_title.add_css_class("heading")
        content_box.append(group_title)

        # Package list — Gtk.ListBox with the .boxed-list class (shipped by the
        # Linux-Lite theme) gives the same rounded-card look as Adw without
        # pulling in libadwaita.
        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self.listbox.add_css_class("boxed-list")
        self.listbox.connect("row-activated", self._on_row_activated)
        content_box.append(self.listbox)

        # Select All row (first row of the list)
        select_all_row = Gtk.ListBoxRow()
        select_all_row.set_activatable(True)
        sa_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                         margin_top=10, margin_bottom=10,
                         margin_start=12, margin_end=12)
        sa_label = Gtk.Label(label=_("Select All"))
        sa_label.set_xalign(0)
        sa_label.set_hexpand(True)
        self.select_all_check = Gtk.CheckButton()
        self.select_all_check.set_valign(Gtk.Align.CENTER)
        self.select_all_check.connect("toggled", self._on_select_all_toggled)
        sa_box.append(sa_label)
        sa_box.append(self.select_all_check)
        select_all_row.set_child(sa_box)
        select_all_row._check = self.select_all_check
        self.listbox.append(select_all_row)

        # Build rows with checkboxes
        self.check_rows = []
        for display_name, icon_name, packages, description in REMOVABLE_PACKAGES:
            installed = get_package_status(packages)
            row = Gtk.ListBoxRow()
            row.set_activatable(installed)

            row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12,
                              margin_top=8, margin_bottom=8,
                              margin_start=12, margin_end=12)

            icon = Gtk.Image.new_from_icon_name(icon_name)
            icon.set_pixel_size(24)
            row_box.append(icon)

            text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
            text_box.set_valign(Gtk.Align.CENTER)
            text_box.set_hexpand(True)
            name_label = Gtk.Label(label=display_name)
            name_label.set_xalign(0)
            subtitle = Gtk.Label()
            subtitle.set_xalign(0)
            subtitle.set_wrap(True)
            if installed:
                subtitle.set_label(description)
            else:
                subtitle.set_label(_("{description}  —  not installed").format(description=description))
            text_box.append(name_label)
            text_box.append(subtitle)
            row_box.append(text_box)

            check = Gtk.CheckButton()
            check.set_sensitive(installed)
            check.set_valign(Gtk.Align.CENTER)
            check.connect("toggled", self._on_check_toggled)
            row_box.append(check)

            row.set_child(row_box)
            row._check = check
            row._subtitle = subtitle

            self.check_rows.append((display_name, packages, description, row, check, installed))
            self.listbox.append(row)

        # Progress bar (hidden initially)
        self.progress_bar = Gtk.ProgressBar()
        self.progress_bar.set_show_text(True)
        self.progress_bar.set_visible(False)
        content_box.append(self.progress_bar)

        # Status label (hidden initially)
        self.status_label = Gtk.Label()
        self.status_label.set_wrap(True)
        self.status_label.set_xalign(0)
        self.status_label.set_visible(False)
        content_box.append(self.status_label)

        # Remove button
        self.remove_btn = Gtk.Button(label=_("Remove Selected"))
        self.remove_btn.add_css_class("destructive-action")
        self.remove_btn.add_css_class("pill")
        self.remove_btn.set_halign(Gtk.Align.CENTER)
        self.remove_btn.set_size_request(200, -1)
        self.remove_btn.set_sensitive(False)
        self.remove_btn.connect("clicked", self._on_remove_clicked)
        content_box.append(self.remove_btn)

    def _on_row_activated(self, listbox, row):
        """Toggle a row's checkbox when the row itself is clicked."""
        chk = getattr(row, "_check", None)
        if chk is not None and chk.get_sensitive():
            chk.set_active(not chk.get_active())

    def _on_check_toggled(self, checkbox):
        """Update Remove button sensitivity based on selections."""
        any_selected = any(
            check.get_active() and installed
            for name, pkgs, desc, row, check, installed in self.check_rows
        )
        self.remove_btn.set_sensitive(any_selected)

    def _on_select_all_toggled(self, checkbox):
        """Toggle all installed package checkboxes."""
        active = checkbox.get_active()
        for name, pkgs, desc, row, check, installed in self.check_rows:
            if installed:
                check.set_active(active)

    def _on_remove_clicked(self, button):
        """Gather selected packages and confirm removal."""
        selected = [
            (name, pkgs)
            for name, pkgs, desc, row, check, installed in self.check_rows
            if check.get_active() and installed
        ]

        if not selected:
            self._info_dialog(_("No Selection"),
                              _("Please select at least one application to remove."))
            return

        self._present_confirm_dialog(selected)

    def _info_dialog(self, heading, body):
        dialog = Gtk.AlertDialog()
        dialog.set_modal(True)
        dialog.set_message(heading)
        dialog.set_detail(body)
        dialog.set_buttons([_("OK")])
        dialog.set_default_button(0)
        dialog.set_cancel_button(0)
        dialog.show(self)

    def _present_confirm_dialog(self, selected):
        names = ", ".join(n for n, _ in selected)
        dialog = Gtk.AlertDialog()
        dialog.set_modal(True)
        dialog.set_message(_("Confirm Removal"))
        dialog.set_detail(_("The following will be removed:\n\n{names}\n\nThis cannot be undone.").format(names=names))
        dialog.set_buttons([_("Cancel"), _("Remove")])
        dialog.set_cancel_button(0)
        dialog.set_default_button(0)
        dialog.choose(self, None, self._on_confirm_chosen, selected)

    def _on_confirm_chosen(self, dialog, result, selected):
        """Handle confirmation dialog response."""
        try:
            index = dialog.choose_finish(result)
        except GLib.Error:
            return
        if index == 1:
            self._run_removal(selected)

    def _run_removal(self, selected):
        """Remove selected packages in a background thread."""
        self._running = True
        self.remove_btn.set_sensitive(False)
        self.progress_bar.set_visible(True)
        self.status_label.set_visible(True)

        # Collect all package names
        all_pkgs = []
        for name, pkgs in selected:
            all_pkgs.extend(pkgs.split())

        total = len(selected)

        def worker():
            removed = []
            failed = []

            for i, (name, pkgs) in enumerate(selected):
                GLib.idle_add(self.status_label.set_label, _("Removing {name}...").format(name=name))
                GLib.idle_add(self.progress_bar.set_fraction, i / total)
                GLib.idle_add(
                    self.progress_bar.set_text,
                    _("{n} of {total}").format(n=i + 1, total=total)
                )

                pkg_list = pkgs.split()

                # Safety net: never let a purge cascade into the Lite suite
                # itself. If apt would also remove a protected package, skip
                # this entry rather than self-destruct.
                protected = purge_would_remove_protected(pkg_list)
                if protected:
                    failed.append(name)
                    continue

                try:
                    result = subprocess.run(
                        ["apt-get", "purge", "-y"] + pkg_list,
                        capture_output=True, text=True, timeout=300
                    )
                    if result.returncode == 0:
                        removed.append(name)
                    else:
                        failed.append(name)
                except Exception:
                    failed.append(name)

            # Remove associated desktop files for ALL uninstalled apps
            # Clean from both /usr/share/applications and ~/.local/share/applications
            import glob as _glob
            uid = os.environ.get("PKEXEC_UID") or os.environ.get("SUDO_UID")
            if uid:
                import pwd
                user_home = pwd.getpwuid(int(uid)).pw_dir
            else:
                user_home = os.path.expanduser("~")
            cleanup_dirs = [
                "/usr/share/applications",
                os.path.join(user_home, ".local", "share", "applications"),
            ]
            for app_name, desktop_files in DESKTOP_FILE_CLEANUP.items():
                # Find the package string for this app
                pkg_str = None
                for dname, _unused1, pkgs, _unused2 in REMOVABLE_PACKAGES:
                    if dname == app_name:
                        pkg_str = pkgs
                        break
                # If the app is not installed, remove its desktop files
                if pkg_str and not get_package_status(pkg_str):
                    for apps_dir in cleanup_dirs:
                        for pattern in desktop_files:
                            for desktop_path in _glob.glob(
                                os.path.join(apps_dir, pattern)
                            ):
                                try:
                                    os.remove(desktop_path)
                                except OSError:
                                    pass

            # Purge hidden packages
            GLib.idle_add(self.status_label.set_label, _("Cleaning up..."))
            GLib.idle_add(self.progress_bar.set_fraction, 0.85)
            hidden_cmd = "apt-get purge -y " + " ".join(HIDDEN_PACKAGES)
            subprocess.run(
                hidden_cmd, shell=True,
                capture_output=True, text=True, timeout=300
            )

            # Autoremove leftover dependencies. `--purge` is essential —
            # plain `autoremove` keeps conffiles, and apt-hook-shipping
            # packages (e.g. ubuntu-helper-virt-hwe with its
            # /etc/apt/apt.conf.d/99-ubuntu-virt.conf) leave the hook config
            # behind pointing at a now-missing binary, breaking every
            # subsequent apt transaction with exit-127.
            GLib.idle_add(self.status_label.set_label, _("Removing leftover dependencies..."))
            GLib.idle_add(self.progress_bar.set_fraction, 0.9)
            subprocess.run(
                ["apt-get", "autoremove", "--purge", "-y"],
                capture_output=True, text=True, timeout=300
            )

            GLib.idle_add(self._removal_finished, removed, failed)

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

    def _removal_finished(self, removed, failed):
        """Called on the main thread when removal is done."""
        self._running = False
        self.progress_bar.set_fraction(1.0)
        self.progress_bar.set_text(_("Done"))

        # Build summary
        lines = []
        if removed:
            lines.append(_("Removed: {items}").format(items=", ".join(removed)))
        if failed:
            lines.append(_("Failed: {items}").format(items=", ".join(failed)))

        self.status_label.set_label("\n".join(lines))

        # Refresh the row states
        for i, (name, pkgs, desc, row, check, _unused) in enumerate(self.check_rows):
            installed = get_package_status(pkgs)
            check.set_active(False)
            check.set_sensitive(installed)
            row.set_activatable(installed)
            if installed:
                row._subtitle.set_label(desc)
            else:
                row._subtitle.set_label(_("{desc}  —  removed").format(desc=desc))
            self.check_rows[i] = (name, pkgs, desc, row, check, installed)

        self.remove_btn.set_sensitive(True)

        self._info_dialog(_("Removal Complete"), "\n".join(lines))


class LiteCoreApp(Gtk.Application):
    """Application class."""

    def __init__(self):
        super().__init__(application_id=APP_ID)

    def do_activate(self):
        win = self.props.active_window
        if not win:
            win = LiteCoreWindow(self)
        win.present()


def main():
    # Re-exec with pkexec if not root
    if os.geteuid() != 0:
        try:
            with open(ENV_FILE, "w") as _ef:
                for _v in ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES", "LC_NUMERIC"):
                    if _v in os.environ:
                        _ef.write(f"{_v}={os.environ[_v]}\n")
        except Exception:
            pass
        try:
            os.execvp("pkexec", ["pkexec", os.path.abspath(__file__)] + sys.argv[1:])
        except Exception as e:
            print(f"Failed to obtain root privileges: {e}", file=sys.stderr)
            sys.exit(1)

    app = LiteCoreApp()
    app.run(sys.argv)


if __name__ == "__main__":
    main()
