#!/usr/bin/env python3
# lin-clean - Safe system cleaner for Linux
# Copyright (C) 2026 Bjarte Kaldestad
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

import shutil
import subprocess
import threading
import time
import tkinter as tk

from pathlib import Path
from tkinter import messagebox, ttk


APP_ID = "io.github.linclean.LinClean"
APP_TITLE = "Lin Clean"
JOURNAL_RETENTION = "7d"


# -------------------------------------------------
# Command helpers
# -------------------------------------------------

def run_command(cmd):
    try:
        proc = subprocess.run(
            cmd,
            text=True,
            capture_output=True,
            check=False,
        )

        return (
            proc.returncode,
            proc.stdout.strip(),
            proc.stderr.strip(),
        )

    except Exception as exc:
        return 1, "", str(exc)


def run_privileged(args):
    """
    Run a command with administrative privileges.

    Prefer pkexec when available because Lin Clean is a graphical
    application. Fall back to sudo if pkexec is unavailable.
    """

    if shutil.which("pkexec"):
        return run_command(["pkexec"] + args)

    return run_command(["sudo"] + args)


# -------------------------------------------------
# Main application
# -------------------------------------------------

class CleanupApp:

    def __init__(self, root: tk.Tk):

        self.root = root

        self.root.title(APP_TITLE)
        self.root.geometry("920x700")
        self.root.minsize(820, 620)

        # -------------------------------------------------
        # Cleanup selections
        # -------------------------------------------------

        self.var_zypper = tk.BooleanVar(value=True)
        self.var_snapper = tk.BooleanVar(value=True)
        self.var_journal = tk.BooleanVar(value=True)
        self.var_cache = tk.BooleanVar(value=False)
        self.var_flatpak = tk.BooleanVar(value=False)

        # -------------------------------------------------
        # Status variables
        # -------------------------------------------------

        self.status_var = tk.StringVar(value="Ready.")

        self.disk_var = tk.StringVar(
            value="Disk: checking …"
        )

        self.home_var = tk.StringVar(
            value="Home: checking …"
        )

        self.snapshots_var = tk.StringVar(
            value="Snapshots: checking …"
        )

        self.before_var = tk.StringVar(
            value="Before: —"
        )

        self.after_var = tk.StringVar(
            value="After: —"
        )

        self._build_style()
        self._build_ui()

        self.refresh_info()


    # -------------------------------------------------
    # Styling
    # -------------------------------------------------

    def _build_style(self):

        style = ttk.Style()

        if "clam" in style.theme_names():
            style.theme_use("clam")

        bg = "#f3f5f7"
        card = "#ffffff"
        accent = "#0b5cad"
        text = "#1f2328"
        muted = "#5c6670"
        border = "#cfd6dd"

        self.root.configure(bg=bg)

        style.configure(
            ".",
            font=("Noto Sans", 10),
        )

        style.configure(
            "App.TFrame",
            background=bg,
        )

        style.configure(
            "Card.TFrame",
            background=card,
            relief="solid",
            borderwidth=1,
        )

        style.configure(
            "CardTitle.TLabel",
            background=card,
            foreground=text,
            font=("Noto Sans", 12, "bold"),
        )

        style.configure(
            "Title.TLabel",
            background=bg,
            foreground=text,
            font=("Noto Sans", 18, "bold"),
        )

        style.configure(
            "Subtitle.TLabel",
            background=bg,
            foreground=muted,
            font=("Noto Sans", 10),
        )

        style.configure(
            "Body.TLabel",
            background=card,
            foreground=text,
            font=("Noto Sans", 10),
        )

        style.configure(
            "Muted.TLabel",
            background=card,
            foreground=muted,
            font=("Noto Sans", 9),
        )

        style.configure(
            "Status.TLabel",
            background=bg,
            foreground=muted,
            font=("Noto Sans", 9),
        )

        style.configure(
            "Primary.TButton",
            padding=(14, 8),
            font=("Noto Sans", 10, "bold"),
        )

        style.configure(
            "TButton",
            padding=(10, 6),
        )

        style.configure(
            "TCheckbutton",
            background=card,
        )

        style.map(
            "TCheckbutton",
            background=[
                ("active", card),
            ],
        )

        style.configure(
            "Disk.Horizontal.TProgressbar",
            thickness=16,
            troughcolor="#dfe5ea",
            background=accent,
            bordercolor=border,
        )


    # -------------------------------------------------
    # Main UI
    # -------------------------------------------------

    def _build_ui(self):

        outer = ttk.Frame(
            self.root,
            style="App.TFrame",
            padding=16,
        )

        outer.pack(
            fill="both",
            expand=True,
        )

        ttk.Label(
            outer,
            text="Safe system cleaner for openSUSE",
            style="Title.TLabel",
        ).pack(
            anchor="w",
        )

        ttk.Label(
            outer,
            text=(
                "A simple GUI for cleaning package cache, old snapshots, "
                "old journal logs, user cache, and unused Flatpak data."
            ),
            style="Subtitle.TLabel",
            wraplength=860,
        ).pack(
            anchor="w",
            pady=(4, 14),
        )

        # -------------------------------------------------
        # Top area
        # -------------------------------------------------

        top = ttk.Frame(
            outer,
            style="App.TFrame",
        )

        top.pack(
            fill="x",
        )

        self._build_overview_card(top).pack(
            side="left",
            fill="both",
            expand=True,
            padx=(0, 8),
        )

        self._build_actions_card(top).pack(
            side="left",
            fill="both",
            expand=True,
            padx=(8, 0),
        )

        # -------------------------------------------------
        # Bottom / log area
        # -------------------------------------------------

        bottom = ttk.Frame(
            outer,
            style="App.TFrame",
        )

        bottom.pack(
            fill="both",
            expand=True,
            pady=(14, 0),
        )

        self._build_log_card(bottom).pack(
            fill="both",
            expand=True,
        )

        # -------------------------------------------------
        # Status bar
        # -------------------------------------------------

        status_bar = ttk.Frame(
            outer,
            style="App.TFrame",
        )

        status_bar.pack(
            fill="x",
            pady=(10, 0),
        )

        ttk.Separator(
            status_bar,
        ).pack(
            fill="x",
            pady=(0, 8),
        )

        ttk.Label(
            status_bar,
            textvariable=self.status_var,
            style="Status.TLabel",
        ).pack(
            side="left",
        )


    # -------------------------------------------------
    # Generic card
    # -------------------------------------------------

    def _card(self, parent, title_text):

        card = ttk.Frame(
            parent,
            style="Card.TFrame",
            padding=16,
        )

        ttk.Label(
            card,
            text=title_text,
            style="CardTitle.TLabel",
        ).pack(
            anchor="w",
        )

        return card


    # -------------------------------------------------
    # Overview card
    # -------------------------------------------------

    def _build_overview_card(self, parent):

        card = self._card(
            parent,
            "Overview",
        )

        body = ttk.Frame(
            card,
            style="Card.TFrame",
        )

        body.pack(
            fill="both",
            expand=True,
            pady=(10, 0),
        )

        ttk.Label(
            body,
            textvariable=self.disk_var,
            style="Body.TLabel",
            wraplength=380,
        ).pack(
            anchor="w",
        )

        self.disk_bar = ttk.Progressbar(
            body,
            style="Disk.Horizontal.TProgressbar",
            mode="determinate",
            maximum=100,
        )

        self.disk_bar.pack(
            fill="x",
            pady=(10, 12),
        )

        ttk.Label(
            body,
            textvariable=self.home_var,
            style="Body.TLabel",
            wraplength=380,
        ).pack(
            anchor="w",
            pady=(0, 6),
        )

        ttk.Label(
            body,
            textvariable=self.snapshots_var,
            style="Body.TLabel",
            wraplength=380,
        ).pack(
            anchor="w",
            pady=(0, 12),
        )

        stats = ttk.Frame(
            body,
            style="Card.TFrame",
        )

        stats.pack(
            fill="x",
            pady=(0, 12),
        )

        ttk.Label(
            stats,
            textvariable=self.before_var,
            style="Muted.TLabel",
        ).pack(
            side="left",
        )

        ttk.Label(
            stats,
            textvariable=self.after_var,
            style="Muted.TLabel",
        ).pack(
            side="left",
            padx=(18, 0),
        )

        buttons = ttk.Frame(
            body,
            style="Card.TFrame",
        )

        buttons.pack(
            fill="x",
        )

        ttk.Button(
            buttons,
            text="Refresh",
            command=self.refresh_info,
        ).pack(
            side="left",
        )

        ttk.Button(
            buttons,
            text="Show large folders in Home",
            command=self.show_home_sizes,
        ).pack(
            side="left",
            padx=(8, 0),
        )

        ttk.Button(
            buttons,
            text="Open Filelight",
            command=self.open_filelight,
        ).pack(
            side="left",
            padx=(8, 0),
        )

        return card


    # -------------------------------------------------
    # Actions card
    # -------------------------------------------------

    def _build_actions_card(self, parent):

        card = self._card(
            parent,
            "Choose what to clean",
        )

        body = ttk.Frame(
            card,
            style="Card.TFrame",
        )

        body.pack(
            fill="both",
            expand=True,
            pady=(10, 0),
        )

        checks = ttk.Frame(
            body,
            style="Card.TFrame",
        )

        checks.pack(
            fill="x",
        )

        ttk.Checkbutton(
            checks,
            text="Clean package cache (zypper clean --all)",
            variable=self.var_zypper,
        ).pack(
            anchor="w",
            pady=4,
        )

        ttk.Checkbutton(
            checks,
            text="Clean old snapshots (snapper cleanup number)",
            variable=self.var_snapper,
        ).pack(
            anchor="w",
            pady=4,
        )

        ttk.Checkbutton(
            checks,
            text=(
                f"Clean journald logs older than "
                f"{JOURNAL_RETENTION} "
                f"(journalctl --vacuum-time={JOURNAL_RETENTION})"
            ),
            variable=self.var_journal,
        ).pack(
            anchor="w",
            pady=4,
        )

        ttk.Checkbutton(
            checks,
            text="Empty user cache (~/.cache)",
            variable=self.var_cache,
        ).pack(
            anchor="w",
            pady=4,
        )

        ttk.Checkbutton(
            checks,
            text="Remove unused Flatpak data",
            variable=self.var_flatpak,
        ).pack(
            anchor="w",
            pady=4,
        )

        ttk.Label(
            body,
            text=(
                "Note: this tool does not wipe free space "
                "and does not manually delete specific snapshots."
            ),
            style="Muted.TLabel",
            wraplength=380,
        ).pack(
            anchor="w",
            pady=(14, 12),
        )

        actions = ttk.Frame(
            body,
            style="Card.TFrame",
        )

        actions.pack(
            fill="x",
        )

        self.run_button = ttk.Button(
            actions,
            text="Run cleaner",
            style="Primary.TButton",
            command=self.run_cleanup,
        )

        self.run_button.pack(
            side="left",
        )

        ttk.Button(
            actions,
            text="Empty log",
            command=self._clear_log,
        ).pack(
            side="left",
            padx=(8, 0),
        )

        return card


    # -------------------------------------------------
    # Log card
    # -------------------------------------------------

    def _build_log_card(self, parent):

        card = self._card(
            parent,
            "Log",
        )

        body = ttk.Frame(
            card,
            style="Card.TFrame",
        )

        body.pack(
            fill="both",
            expand=True,
            pady=(10, 0),
        )

        self.log = tk.Text(
            body,
            wrap="word",
            height=16,
            relief="flat",
            borderwidth=0,
            padx=10,
            pady=10,
            font=("JetBrains Mono", 10),
            bg="#fcfcfd",
            fg="#1f2328",
            insertbackground="#1f2328",
        )

        self.log.pack(
            side="left",
            fill="both",
            expand=True,
        )

        scroll = ttk.Scrollbar(
            body,
            orient="vertical",
            command=self.log.yview,
        )

        scroll.pack(
            side="right",
            fill="y",
        )

        self.log.configure(
            yscrollcommand=scroll.set,
        )

        self._log_gui(
            "Start the program."
        )

        self._log_gui(
            "Choose what to clean, then press 'Run cleaner'."
        )

        return card


    # -------------------------------------------------
    # Logging
    # -------------------------------------------------

    def _log(self, text: str):
        """
        Thread-safe logging.

        Worker threads call this function.
        The actual Tk widget update is scheduled on
        the Tk main event loop.
        """

        self.root.after(
            0,
            self._log_gui,
            text,
        )


    def _log_gui(self, text: str):

        stamp = time.strftime("%H:%M:%S")

        self.log.insert(
            "end",
            f"[{stamp}] {text}\n",
        )

        self.log.see(
            "end",
        )


    def _clear_log(self):

        self.log.delete(
            "1.0",
            "end",
        )

        self._log_gui(
            "Log emptied."
        )


    # -------------------------------------------------
    # Disk information
    # -------------------------------------------------

    def refresh_info(self):

        usage = shutil.disk_usage("/")

        used = usage.total - usage.free

        pct = (
            int((used / usage.total) * 100)
            if usage.total
            else 0
        )

        self.disk_var.set(
            f"Disk (/): "
            f"{self._human(used)} used of "
            f"{self._human(usage.total)}  |  "
            f"Free: {self._human(usage.free)}  |  "
            f"{pct}% used"
        )

        self.disk_bar["value"] = pct

        self.home_var.set(
            "Home: "
            + self._folder_size_text(
                Path.home()
            )
        )

        self.snapshots_var.set(
            "/.snapshots: "
            + self._folder_size_text(
                Path("/.snapshots")
            )
        )

        self.before_var.set(
            f"Before: "
            f"{self._human(used)} used / "
            f"{self._human(usage.total)}"
        )


    def _human(self, num_bytes):

        return (
            f"{num_bytes / (1024 ** 3):.0f} GiB"
        )


    def _folder_size_text(self, path: Path):

        if not path.exists():
            return "does not exist"

        code, out, err = run_command(
            [
                "du",
                "-sh",
                str(path),
            ]
        )

        if code == 0 and out:
            return out.split()[0]

        return (
            f"could not read "
            f"({err or 'unknown error'})"
        )


    # -------------------------------------------------
    # Home folder size viewer
    # -------------------------------------------------

    def show_home_sizes(self):

        command = [
            "du",
            "-xhd1",
            str(Path.home()),
        ]

        code, out, err = run_command(
            command
        )

        if code != 0:

            messagebox.showerror(
                APP_TITLE,
                err or "Could not read Home.",
            )

            return

        # Sort output by size using Python-safe external sort.

        try:
            sort_proc = subprocess.run(
                ["sort", "-h"],
                input=out,
                text=True,
                capture_output=True,
                check=False,
            )

            if sort_proc.returncode == 0:
                out = sort_proc.stdout.strip()

        except Exception:
            pass

        win = tk.Toplevel(
            self.root
        )

        win.title(
            "Large folders in Home"
        )

        win.geometry(
            "760x500"
        )

        win.minsize(
            600,
            350,
        )

        win.configure(
            bg="#f3f5f7"
        )

        outer = ttk.Frame(
            win,
            style="App.TFrame",
            padding=14,
        )

        outer.pack(
            fill="both",
            expand=True,
        )

        ttk.Label(
            outer,
            text="Large folders in Home",
            style="Title.TLabel",
        ).pack(
            anchor="w",
        )

        ttk.Label(
            outer,
            text=(
                "A quick way to find old projects, "
                "downloads, VMs, and games."
            ),
            style="Subtitle.TLabel",
            wraplength=700,
        ).pack(
            anchor="w",
            pady=(4, 10),
        )

        box = ttk.Frame(
            outer,
            style="Card.TFrame",
            padding=12,
        )

        box.pack(
            fill="both",
            expand=True,
        )

        text = tk.Text(
            box,
            wrap="none",
            relief="flat",
            borderwidth=0,
            font=("JetBrains Mono", 10),
            bg="#fcfcfd",
            fg="#1f2328",
        )

        text.pack(
            side="left",
            fill="both",
            expand=True,
        )

        yscroll = ttk.Scrollbar(
            box,
            orient="vertical",
            command=text.yview,
        )

        yscroll.pack(
            side="right",
            fill="y",
        )

        text.configure(
            yscrollcommand=yscroll.set,
        )

        text.insert(
            "1.0",
            out + "\n",
        )

        text.configure(
            state="disabled",
        )


    # -------------------------------------------------
    # Filelight
    # -------------------------------------------------

    def open_filelight(self):

        if not shutil.which("filelight"):

            messagebox.showinfo(
                APP_TITLE,
                (
                    "Filelight is not installed.\n\n"
                    "Install it with:\n"
                    "sudo zypper install filelight"
                ),
            )

            return

        subprocess.Popen(
            ["filelight"]
        )


    # -------------------------------------------------
    # Start cleanup
    # -------------------------------------------------

    def run_cleanup(self):

        # Read Tk variables in the main GUI thread.

        options = {
            "zypper": self.var_zypper.get(),
            "snapper": self.var_snapper.get(),
            "journal": self.var_journal.get(),
            "cache": self.var_cache.get(),
            "flatpak": self.var_flatpak.get(),
        }

        selected = any(
            options.values()
        )

        if not selected:

            messagebox.showinfo(
                APP_TITLE,
                "Choose at least one thing to clean.",
            )

            return

        answer = messagebox.askyesno(
            APP_TITLE,
            (
                "Run the selected cleanup now?\n\n"
                "You may be asked for your password."
            ),
        )

        if not answer:
            return

        self.run_button.config(
            state="disabled"
        )

        self.status_var.set(
            "Running …"
        )

        self._log_gui("")
        self._log_gui("Starting cleanup …")

        thread = threading.Thread(
            target=self._cleanup_worker,
            args=(options,),
            daemon=True,
        )

        thread.start()


    # -------------------------------------------------
    # Cleanup worker thread
    # -------------------------------------------------

    def _cleanup_worker(self, options):

        any_fail = False

        # -------------------------------------------------
        # Zypper cache
        # -------------------------------------------------

        if options["zypper"]:

            any_fail |= not self._run_step(
                "Cleaning package cache",
                [
                    "zypper",
                    "clean",
                    "--all",
                ],
                privileged=True,
            )

        # -------------------------------------------------
        # Snapper
        # -------------------------------------------------

        if options["snapper"]:

            any_fail |= not self._run_step(
                "Cleaning old snapshots",
                [
                    "snapper",
                    "cleanup",
                    "number",
                ],
                privileged=True,
            )

        # -------------------------------------------------
        # Journald
        # -------------------------------------------------

        if options["journal"]:

            any_fail |= not self._run_step(
                (
                    "Cleaning journald logs older than "
                    f"{JOURNAL_RETENTION}"
                ),
                [
                    "journalctl",
                    f"--vacuum-time={JOURNAL_RETENTION}",
                ],
                privileged=True,
            )

        # -------------------------------------------------
        # User cache
        # -------------------------------------------------

        if options["cache"]:

            self._log("")
            self._log("==> Emptying ~/.cache")

            any_fail |= not self._clear_user_cache()

        # -------------------------------------------------
        # Flatpak
        # -------------------------------------------------

        if options["flatpak"]:

            if not shutil.which("flatpak"):

                self._log("")
                self._log(
                    "[ERROR] Flatpak is not installed."
                )

                any_fail = True

            else:

                any_fail |= not self._run_step(
                    "Removing unused Flatpak data",
                    [
                        "flatpak",
                        "uninstall",
                        "--unused",
                        "-y",
                    ],
                    privileged=False,
                )

        # Return final GUI work to the main Tk thread.

        self.root.after(
            0,
            self._cleanup_done,
            any_fail,
        )


    # -------------------------------------------------
    # Run one cleanup operation
    # -------------------------------------------------

    def _run_step(
        self,
        title,
        args,
        privileged=False,
    ):

        self._log("")
        self._log(
            f"==> {title}"
        )

        self._log(
            "$ " + " ".join(args)
        )

        if privileged:

            code, out, err = run_privileged(
                args
            )

        else:

            code, out, err = run_command(
                args
            )

        if out:
            self._log(out)

        if err:
            self._log(err)

        if code != 0:

            self._log(
                f"[ERROR] Exit code {code}"
            )

            return False

        self._log(
            "[OK]"
        )

        return True


    # -------------------------------------------------
    # Clear ~/.cache
    # -------------------------------------------------

    def _clear_user_cache(self):

        cache_dir = (
            Path.home()
            / ".cache"
        )

        if not cache_dir.exists():

            self._log(
                "~/.cache does not exist."
            )

            return True

        removed = 0
        failed = 0

        try:
            items = list(
                cache_dir.iterdir()
            )

        except Exception as exc:

            self._log(
                f"[ERROR] Could not read ~/.cache: {exc}"
            )

            return False

        for item in items:

            try:

                if (
                    item.is_dir()
                    and not item.is_symlink()
                ):

                    shutil.rmtree(
                        item
                    )

                else:

                    item.unlink(
                        missing_ok=True
                    )

                removed += 1

            except Exception as exc:

                failed += 1

                self._log(
                    f"Could not delete {item}: {exc}"
                )

        if failed:

            self._log(
                (
                    f"[WARNING] Removed {removed} entries, "
                    f"but {failed} could not be removed."
                )
            )

            return False

        self._log(
            (
                f"[OK] Emptied ~/.cache "
                f"({removed} entries)."
            )
        )

        return True


    # -------------------------------------------------
    # Cleanup finished
    # -------------------------------------------------

    def _cleanup_done(self, any_fail):

        self.run_button.config(
            state="normal"
        )

        self.refresh_info()

        usage = shutil.disk_usage("/")

        used = (
            usage.total
            - usage.free
        )

        self.after_var.set(
            (
                f"After: "
                f"{self._human(used)} used / "
                f"{self._human(usage.total)}"
            )
        )

        if any_fail:

            self.status_var.set(
                "Finished with one or more errors."
            )

            self._log_gui("")
            self._log_gui(
                "Finished with one or more errors."
            )

            messagebox.showwarning(
                APP_TITLE,
                (
                    "Cleaning finished, but one or more "
                    "steps failed.\n\n"
                    "See the log for details."
                ),
            )

        else:

            self.status_var.set(
                "Cleaning finished."
            )

            self._log_gui("")
            self._log_gui(
                "Cleaning finished."
            )

            messagebox.showinfo(
                APP_TITLE,
                "Cleaning finished.",
            )


# -------------------------------------------------
# Main
# -------------------------------------------------

def main():

    root = tk.Tk()

    CleanupApp(
        root
    )

    root.mainloop()


if __name__ == "__main__":
    main()
