#!/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"


def run_command(cmd, shell=False):
    try:
        proc = subprocess.run(
            cmd,
            shell=shell,
            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 sh_quote(text: str) -> str:
    return "'" + text.replace("'", "'\"'\"'") + "'"


def run_privileged(args):
    if shutil.which("pkexec"):
        quoted = " ".join(sh_quote(a) for a in args)
        return run_command(["pkexec", "sh", "-lc", quoted])
    return run_command(["sudo"] + args)


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)

        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)

        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()

    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,
        )

    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 = 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 = 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 = 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")

    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

    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

    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 {JOURNAL_RETENTION} (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

    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("Start the program.")
        self._log("Choose what to clean, then press 'Run cleaner'.")
        return card

    def _log(self, text: str):
        stamp = time.strftime("%H:%M:%S")
        self.log.insert("end", f"[{stamp}] {text}\n")
        self.log.see("end")
        self.root.update_idletasks()

    def _clear_log(self):
        self.log.delete("1.0", "end")
        self._log("Log emptied.")

    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 (/): {self._human(used)} used of {self._human(usage.total)}  |  "
            f"Free: {self._human(usage.free)}  |  {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: {self._human(used)} used / {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 ({err or 'unknown error'})"

    def show_home_sizes(self):
        code, out, err = run_command(f'du -xhd1 "{Path.home()}" 2>/dev/null | sort -h', shell=True)
        if code != 0:
            messagebox.showerror(APP_TITLE, err or "Could not read Home.")
            return

        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")

    def open_filelight(self):
        if not shutil.which("filelight"):
            messagebox.showinfo(
                APP_TITLE,
                "Filelight is not installed.\n\nInstall it with:\nsudo zypper install filelight",
            )
            return
        subprocess.Popen(["filelight"])

    def run_cleanup(self):
        selected = any([
            self.var_zypper.get(),
            self.var_snapper.get(),
            self.var_journal.get(),
            self.var_cache.get(),
            self.var_flatpak.get(),
        ])
        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\nYou may be asked for your password.")
        if not answer:
            return

        self.run_button.config(state="disabled")
        self.status_var.set("Running …")
        self._log("")
        self._log("Starting cleanup …")

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

    def _cleanup_worker(self):
        any_fail = False

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

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

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

        if self.var_cache.get():
            self._log("")
            self._log("==> Emptying ~/.cache")
            self._clear_user_cache()

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

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

    def _run_step(self, title, args, privileged=False):
        self._log("")
        self._log(f"==> {title}")
        self._log("$ " + " ".join(args))

        code, out, err = run_privileged(args) if privileged else 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

    def _clear_user_cache(self):
        cache_dir = Path.home() / ".cache"
        if not cache_dir.exists():
            self._log("~/.cache does not exist.")
            return

        removed = 0
        for item in cache_dir.iterdir():
            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:
                self._log(f"Could not delete {item}: {exc}")

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

    def _cleanup_done(self, any_fail):
        self.run_button.config(state="normal")
        self.refresh_info()

        usage = shutil.disk_usage("/")
        self.after_var.set(f"After: {self._human(usage.total - usage.free)} used / {self._human(usage.total)}")

        if any_fail:
            self.status_var.set("Finished with one or more errors.")
            self._log("")
            self._log("Finished with one or more errors.")
            messagebox.showwarning(APP_TITLE, "Cleaning finished, but one or more steps failed.\n\nSee the log for details.")
        else:
            self.status_var.set("Cleaning finished.")
            self._log("")
            self._log("Cleaning finished.")
            messagebox.showinfo(APP_TITLE, "Cleaning finished.")


def main():
    root = tk.Tk()
    CleanupApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
