"""Cleanup — System aufräumen über die Maintain-Scanner.

Portiert die UI-freien Scanner (taninux.maintain) in eine libadwaita-Seite.
Scannen läuft async im Worker-Thread; Findings werden per GLib.idle_add
gerendert. Destruktive Aktionen gehen ausschliesslich über core.trash
(move-to-trash, kein echtes rm) — das Sicherheitsnetz der App.

trash-Scanner (dotfolders, fp-data, aur-cache): User-Dateien → Trash/Keep.
view-Scanner (orphans, cache, …): nur anzeigen + Hinweis-Befehl zum Kopieren.
"""
from __future__ import annotations

import threading

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk  # noqa: E402

from taninux.core import trash
from taninux.core.state import State
from taninux.gui import confirm
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
from taninux.maintain.scanners import SCANNERS, ScannerSpec
from taninux.maintain.types import Classification, Finding

# Klassifizierung → CSS-Klasse für den farbigen Punkt + englisches Label.
_DOT_CLASS = {
    Classification.SAFE: "success",
    Classification.LIKELY_ORPHAN: "warning",
    Classification.UNKNOWN: "dim-label",
    Classification.ACTIVE: "dim-label",
    Classification.WHITELISTED: "accent",
}
_EN_LABEL = {
    Classification.SAFE: "safe",
    Classification.LIKELY_ORPHAN: "likely orphan",
    Classification.UNKNOWN: "unknown",
    Classification.WHITELISTED: "kept",
    Classification.ACTIVE: "in use",
}
_RECLAIMABLE = (Classification.SAFE, Classification.LIKELY_ORPHAN)
_MAX_ROWS = 50  # pro Scanner, gegen UI-Overload


def _clean_argv(spec_id: str, findings: list[Finding]):
    """Sicheres One-Click-Clean für view-Scanner → (argv, privileged) | None.

    Nur ungefährliche, nicht-interaktive Befehle. pacnew (interaktiver Merge)
    und kernels (rm, gefährlich) bleiben bewusst Hinweis-only.
    """
    if spec_id == "cache":
        return ["paccache", "-ruk0"], True
    if spec_id == "journal":
        return ["journalctl", "--vacuum-time=2weeks"], True
    if spec_id == "flatpak-runtimes":
        return ["flatpak", "uninstall", "--unused", "--assumeyes"], False
    if spec_id == "orphans":
        pkgs = [f.matched_package or f.path.name for f in findings]
        pkgs = [p for p in pkgs if p]
        return (["pacman", "-Rns", "--noconfirm", *pkgs], True) if pkgs else None
    return None


def _human(n: int) -> str:
    """Bytes menschlich (B/KiB/MiB/GiB)."""
    size = float(n)
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if size < 1024 or unit == "TiB":
            if unit == "B":
                return f"{int(size)} {unit}"
            return f"{size:.1f} {unit}"
        size /= 1024
    return f"{n} B"  # unerreichbar


def _dot(cls: Classification) -> Gtk.Label:
    dot = Gtk.Label(label="●")
    dot.add_css_class(_DOT_CLASS.get(cls, "dim-label"))
    dot.set_valign(Gtk.Align.CENTER)
    return dot


def _legend() -> Adw.ActionRow:
    """Erklärt die farbigen Punkte."""
    row = Adw.ActionRow(title="Legend")
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
    box.set_valign(Gtk.Align.CENTER)
    for cls in (Classification.SAFE, Classification.LIKELY_ORPHAN,
                Classification.UNKNOWN):
        item = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
        item.append(_dot(cls))
        lbl = Gtk.Label(label=_EN_LABEL[cls])
        lbl.add_css_class("dim-label")
        lbl.add_css_class("caption")
        item.append(lbl)
        box.append(item)
    row.add_suffix(box)
    return row


def _flat_btn(label: str, cb) -> Gtk.Button:
    btn = Gtk.Button(label=label)
    btn.add_css_class("flat")
    btn.set_valign(Gtk.Align.CENTER)
    btn.connect("clicked", lambda _b: cb())
    return btn


def _fill_advice(advice: str, first: Finding | None) -> str:
    """Platzhalter {pkg}/{path} mit dem ersten Finding füllen."""
    if first is None:
        return advice
    pkg = first.matched_package or first.path.name
    return advice.replace("{pkg}", pkg).replace("{path}", str(first.path))


def build() -> Gtk.Widget:
    page = Adw.PreferencesPage()

    # -- Summary (oben) -----------------------------------------------
    g_summary = Adw.PreferencesGroup(title="Cleanup")
    rescan_btn = Gtk.Button(icon_name="view-refresh-symbolic")
    rescan_btn.add_css_class("flat")
    rescan_btn.set_valign(Gtk.Align.CENTER)
    rescan_btn.set_tooltip_text("Rescan")
    g_summary.set_header_suffix(rescan_btn)

    summary_row = Adw.ActionRow(title="Reclaimable", subtitle="Scanning …")
    summary_spin = Adw.Spinner()
    summary_spin.set_valign(Gtk.Align.CENTER)
    summary_row.add_prefix(summary_spin)
    g_summary.add(summary_row)
    g_summary.add(_legend())
    page.add(g_summary)

    console = LogConsole()
    runner = ProcessRunner()
    runner.connect("line", lambda _r, ln: console.append_line(ln))

    # Dynamische Scanner-Gruppen (zwischen Summary und Trash) ---------
    scanner_groups: list[Gtk.Widget] = []

    # -- Trash-Gruppe (unten) -----------------------------------------
    g_trash = Adw.PreferencesGroup(
        title="Trash",
        description="Files moved here are recoverable, nothing is deleted.",
    )
    trash_rows: list[Gtk.Widget] = []
    page.add(g_trash)

    # -- Log-Gruppe (ganz unten, für Clean-Befehle) -------------------
    g_log = log_expander(console)
    page.add(g_log)

    def _clean_run(argv: list[str], priv: bool, label: str) -> None:
        def go() -> None:
            console.clear()
            runner.run(argv, privileged=priv)
        confirm.confirm(rescan_btn, f"Clean {label}",
                        "Run: " + " ".join(argv), go)

    # ----------------------------------------------------------------
    def _clipboard_set(text: str) -> None:
        disp = Gdk.Display.get_default()
        if disp is not None:
            disp.get_clipboard().set(text)

    def _clear_scanner_groups() -> None:
        for grp in scanner_groups:
            page.remove(grp)
        scanner_groups.clear()

    # -- Trash-Liste --------------------------------------------------
    def _refresh_trash() -> None:
        for r in trash_rows:
            g_trash.remove(r)
        trash_rows.clear()

        entries = trash.list_trash()
        if not entries:
            empty = Adw.ActionRow(
                title="Trash is empty",
                subtitle="Trashed items will appear here.",
            )
            g_trash.add(empty)
            trash_rows.append(empty)
            return

        for entry in entries:
            row = Adw.ActionRow(
                title=str(entry.original),
                subtitle=f"{_human(entry.size_bytes)} · {entry.trashed_at}",
            )

            def _restore(eid: str = entry.id) -> None:
                try:
                    trash.restore(eid)
                except (FileExistsError, FileNotFoundError, OSError):
                    pass
                _refresh_trash()

            def _purge(eid: str = entry.id, orig: str = str(entry.original)) -> None:
                def do() -> None:
                    try:
                        trash.purge(eid)
                    except (FileNotFoundError, OSError):
                        pass
                    _refresh_trash()
                confirm.confirm(
                    rescan_btn,
                    "Purge permanently",
                    f"Delete {orig} for good. This cannot be undone.",
                    do,
                )

            row.add_suffix(_flat_btn("Restore", _restore))
            purge_b = _flat_btn("Purge", _purge)
            purge_b.add_css_class("destructive-action")
            row.add_suffix(purge_b)
            g_trash.add(row)
            trash_rows.append(row)

    # -- eine Scanner-Gruppe rendern ----------------------------------
    def _build_group(spec: ScannerSpec, findings: list[Finding]) -> Gtk.Widget:
        findings = sorted(findings, key=lambda f: f.sort_key)
        reclaim = sum(f.size_bytes for f in findings
                      if f.classification in _RECLAIMABLE)
        shown = findings[:_MAX_ROWS]
        hidden = len(findings) - len(shown)

        desc = f"{len(findings)} findings · {_human(reclaim)} reclaimable"
        if hidden > 0:
            desc += f" · showing first {_MAX_ROWS}"
        if spec.actions == "view" and spec.advice:
            desc += "\n" + _fill_advice(spec.advice, findings[0] if findings else None)

        grp = Adw.PreferencesGroup(title=spec.label, description=desc)

        # view-Scanner: Header mit Copy-Button + (falls sicher) Clean-Button.
        if spec.actions == "view":
            hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
            if spec.advice:
                cmd = _fill_advice(spec.advice, findings[0] if findings else None)
                copy = Gtk.Button(icon_name="edit-copy-symbolic")
                copy.add_css_class("flat")
                copy.set_valign(Gtk.Align.CENTER)
                copy.set_tooltip_text("Copy command")
                copy.connect("clicked", lambda _b, c=cmd: _clipboard_set(c))
                hdr.append(copy)
            clean = _clean_argv(spec.id, findings)
            if clean is not None:
                argv, priv = clean
                cb = Gtk.Button(label="Clean")
                cb.add_css_class("destructive-action")
                cb.set_valign(Gtk.Align.CENTER)
                cb.connect("clicked",
                           lambda _b, a=argv, p=priv, lb=spec.label: _clean_run(a, p, lb))
                hdr.append(cb)
            if hdr.get_first_child() is not None:
                grp.set_header_suffix(hdr)

        # Findings in eine scrollbare ListBox: ~5 Zeilen sichtbar, dann scrollen.
        listbox = Gtk.ListBox()
        listbox.add_css_class("boxed-list")
        listbox.set_selection_mode(Gtk.SelectionMode.NONE)

        for finding in shown:
            cls = finding.classification
            title = finding.matched_package or str(finding.path)
            sub = f"{_EN_LABEL.get(cls, cls.value)} · {_human(finding.size_bytes)}"
            if finding.reason:
                sub += f" · {finding.reason}"

            row = Adw.ActionRow(title=title, subtitle=sub)
            row.add_prefix(_dot(cls))

            if spec.actions == "trash":
                def _do_trash(f: Finding = finding, r: Adw.ActionRow = row,
                              lb: Gtk.ListBox = listbox) -> None:
                    def confirmed() -> None:
                        def worker() -> None:
                            ok = True
                            try:
                                trash.move_to_trash(f.path)
                            except (FileNotFoundError, OSError):
                                ok = False
                            GLib.idle_add(lambda: (_after_trash(r, lb, ok), False)[1])
                        threading.Thread(target=worker, daemon=True).start()
                    confirm.confirm(
                        rescan_btn,
                        "Move to Trash",
                        f"Move {f.path} to the recoverable Trash.",
                        confirmed,
                    )

                def _do_keep(f: Finding = finding, r: Adw.ActionRow = row,
                             lb: Gtk.ListBox = listbox) -> None:
                    State.load().whitelist_add(f.path)
                    lb.remove(r)

                row.add_suffix(_flat_btn("Keep", _do_keep))
                trash_b = _flat_btn("Trash", _do_trash)
                trash_b.add_css_class("destructive-action")
                row.add_suffix(trash_b)

            listbox.append(row)

        if hidden > 0:
            more = Adw.ActionRow(title=f"… +{hidden} more")
            more.add_css_class("dim-label")
            listbox.append(more)

        visible = min(len(shown) + (1 if hidden else 0), 5)
        h = visible * 56
        scroller = Gtk.ScrolledWindow()
        scroller.set_min_content_height(h)
        scroller.set_max_content_height(h)
        scroller.set_child(listbox)
        grp.add(scroller)
        return grp

    def _after_trash(row: Adw.ActionRow, listbox: Gtk.ListBox, ok: bool) -> None:
        if ok:
            listbox.remove(row)
            _refresh_trash()

    # -- Scan async ---------------------------------------------------
    def _render(results: list[tuple[ScannerSpec, list[Finding]]]) -> None:
        _clear_scanner_groups()
        summary_spin.set_visible(False)

        # Trash + Log temporär entfernen, damit Scanner-Gruppen davor landen.
        page.remove(g_trash)
        page.remove(g_log)
        total = 0
        for spec, findings in results:
            if not findings:
                continue
            total += sum(f.size_bytes for f in findings
                         if f.classification in _RECLAIMABLE)
            grp = _build_group(spec, findings)
            page.add(grp)
            scanner_groups.append(grp)

        page.add(g_trash)
        page.add(g_log)
        summary_row.set_subtitle(f"{_human(total)} can be reclaimed safely")

    def _scan() -> None:
        _clear_scanner_groups()
        summary_spin.set_visible(True)
        summary_row.set_subtitle("Scanning …")

        def worker() -> None:
            # State einmal laden, um whitelisted/ignored Findings auszublenden.
            try:
                state = State.load()
            except Exception:  # noqa: BLE001 — UI darf nie crashen
                state = None
            results: list[tuple[ScannerSpec, list[Finding]]] = []
            for spec in SCANNERS:
                try:
                    findings = spec.factory().scan()
                except Exception:  # noqa: BLE001 — ein Scanner darf nicht alles killen
                    findings = []
                if state is not None:
                    try:
                        findings = [
                            f for f in findings
                            if not state.is_whitelisted(f.path)
                            and not state.is_ignored(f.path)
                        ]
                    except OSError:
                        pass
                results.append((spec, findings))
            GLib.idle_add(lambda: (_render(results), False)[1])

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

    rescan_btn.connect("clicked", lambda _b: _scan())

    def _on_clean_done(_r: ProcessRunner, code: int) -> None:
        console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
        _refresh_trash()
        _scan()

    runner.connect("done", _on_clean_done)

    _refresh_trash()
    GLib.idle_add(lambda: (_scan(), False)[1])
    return page
