#!/usr/bin/env python3
"""GPlanSiS|Station — agente do Mac.

Lê o sistema em tempo real (CPU, memória, disco, rede, GPU, bateria, processos) e
executa os comandos de limpeza, organização, armazenamento e ajustes. Fala com o
painel web (station.gplansis.com) por uma conexão de saída (WebSocket com TLS):
nenhuma porta é aberta no Mac. Também funciona sozinho no Terminal:

    station status            resumo do Mac
    station ajuda             todos os comandos

Só usa a biblioteca padrão do Python (3.9+). Regras de segurança:
  • apagar de vez só caches, logs e artefatos que se recriam sozinhos, sempre a partir
    de uma lista que o próprio agente montou (o painel não consegue mandar caminhos);
  • arquivos pessoais vão para a Lixeira e podem ser restaurados pelo histórico;
  • comandos livres de shell ficam desligados até você ligar aqui no Mac
    (`station shell ligar`) — o painel não consegue ligar sozinho.
"""
from __future__ import annotations

import base64
import concurrent.futures as cf
import ctypes
import ctypes.util
import datetime as dt
import getpass
import hashlib
import json
import os
import platform
import plistlib
import queue
import re
import shlex
import shutil
import signal
import socket
import ssl
import struct
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path

VERSION = "1.0.0"
HOME = Path.home()
SUPPORT = Path(os.environ["STATION_DIR"]) if os.environ.get("STATION_DIR") else HOME / "Library" / "Application Support" / "GPlanSiS Station"
CONFIG = SUPPORT / "config.json"
HISTORY = SUPPORT / "historico.jsonl"
LOGFILE = SUPPORT / "agente.log"
LABEL = "com.gplansis.station.agente"
PLIST = HOME / "Library" / "LaunchAgents" / (LABEL + ".plist")
DEFAULT_SERVER = "https://station.gplansis.com"
IS_MAC = sys.platform == "darwin"


# ======================================================================= utilidades
def now() -> float:
    return time.time()


def log(*a) -> None:
    line = time.strftime("%Y-%m-%d %H:%M:%S ") + " ".join(str(x) for x in a)
    try:
        SUPPORT.mkdir(parents=True, exist_ok=True)
        if LOGFILE.exists() and LOGFILE.stat().st_size > 2_000_000:
            LOGFILE.replace(LOGFILE.with_suffix(".log.1"))
        with open(LOGFILE, "a", encoding="utf-8") as f:
            f.write(line + "\n")
    except OSError:
        pass
    if os.environ.get("STATION_DEBUG"):
        print(line, file=sys.stderr)


def run(cmd: list[str], timeout: float = 20, input: str | None = None) -> str:
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, input=input)
        return r.stdout
    except (OSError, subprocess.TimeoutExpired):
        return ""


def run_full(cmd: list[str], timeout: float = 60) -> tuple[int, str, str]:
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout, r.stderr
    except subprocess.TimeoutExpired:
        return 124, "", "tempo esgotado"
    except OSError as e:
        return 127, "", str(e)


def human(n: float | None) -> str:
    if n is None:
        return "—"
    n = float(n)
    for u in ("B", "KB", "MB", "GB", "TB"):
        if abs(n) < 1000 or u == "TB":
            return (f"{n:.0f} {u}" if u == "B" or n >= 100 else f"{n:.1f} {u}").replace(".", ",")
        n /= 1000
    return str(n)


def load_config() -> dict:
    try:
        return json.loads(CONFIG.read_text())
    except (OSError, ValueError):
        return {}


_cfg_lock = threading.Lock()


def save_config(cfg: dict) -> None:
    with _cfg_lock:
        SUPPORT.mkdir(parents=True, exist_ok=True)
        tmp = CONFIG.with_suffix(".tmp")
        tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2))
        os.chmod(tmp, 0o600)
        tmp.replace(CONFIG)


class Cancelled(Exception):
    pass


class Ctx:
    """Contexto de um comando: progresso para o painel e cancelamento."""

    def __init__(self, send=None):
        self._send = send
        self.cancel = threading.Event()
        self._last = 0.0

    def progress(self, d: dict, force: bool = False) -> None:
        if self._send and (force or now() - self._last > 0.25):
            self._last = now()
            self._send(d)

    def check(self) -> None:
        if self.cancel.is_set():
            raise Cancelled()


class Fail(Exception):
    """Erro com mensagem para a pessoa."""


# ======================================================================= leitura do sistema (macOS)
if IS_MAC:
    _libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
    _libc.mach_host_self.restype = ctypes.c_uint
    _libc.host_processor_info.argtypes = [ctypes.c_uint, ctypes.c_int, ctypes.POINTER(ctypes.c_uint),
                                          ctypes.POINTER(ctypes.POINTER(ctypes.c_int)), ctypes.POINTER(ctypes.c_uint)]
    _libc.vm_deallocate.argtypes = [ctypes.c_uint, ctypes.c_size_t, ctypes.c_size_t]
    _libc.sysctlbyname.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p, ctypes.c_size_t]
    _HOST = _libc.mach_host_self()
    _TASK = ctypes.c_uint.in_dll(_libc, "mach_task_self_").value

    class _VM64(ctypes.Structure):
        _fields_ = [(n, ctypes.c_uint) for n in ("free", "active", "inactive", "wire")] + \
                   [(n, ctypes.c_uint64) for n in ("zero_fill", "reactivations", "pageins", "pageouts", "faults",
                                                   "cow_faults", "lookups", "hits", "purges")] + \
                   [(n, ctypes.c_uint) for n in ("purgeable", "speculative")] + \
                   [(n, ctypes.c_uint64) for n in ("decompressions", "compressions", "swapins", "swapouts")] + \
                   [(n, ctypes.c_uint) for n in ("compressor", "throttled", "external", "internal")] + \
                   [("uncompressed_in_compressor", ctypes.c_uint64)]

    _libc.host_statistics64.argtypes = [ctypes.c_uint, ctypes.c_int, ctypes.POINTER(_VM64), ctypes.POINTER(ctypes.c_uint)]


def sysctl_raw(name: str) -> bytes | None:
    if not IS_MAC:
        return None
    size = ctypes.c_size_t(0)
    if _libc.sysctlbyname(name.encode(), None, ctypes.byref(size), None, 0) != 0:
        return None
    buf = ctypes.create_string_buffer(size.value)
    if _libc.sysctlbyname(name.encode(), buf, ctypes.byref(size), None, 0) != 0:
        return None
    return buf.raw[: size.value]


def sysctl_int(name: str) -> int | None:
    b = sysctl_raw(name)
    if not b:
        return None
    return int.from_bytes(b[:8] if len(b) >= 8 else b[:4], "little")


def sysctl_str(name: str) -> str:
    b = sysctl_raw(name)
    return b.split(b"\0")[0].decode(errors="replace") if b else ""


def cpu_ticks() -> list[tuple[int, int, int, int]] | None:
    if not IS_MAC:
        return None
    n, cnt = ctypes.c_uint(), ctypes.c_uint()
    info = ctypes.POINTER(ctypes.c_int)()
    if _libc.host_processor_info(_HOST, 2, ctypes.byref(n), ctypes.byref(info), ctypes.byref(cnt)) != 0:
        return None
    out = []
    for i in range(n.value):
        b = i * 4  # CPU_STATE_USER, SYSTEM, IDLE, NICE
        out.append(tuple(info[b + k] & 0xFFFFFFFF for k in range(4)))
    _libc.vm_deallocate(_TASK, ctypes.cast(info, ctypes.c_void_p).value, cnt.value * 4)
    return out


PAGE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096


def memory() -> dict:
    total = sysctl_int("hw.memsize") or 0
    d = {"total": total}
    if IS_MAC:
        vm = _VM64()
        cnt = ctypes.c_uint(ctypes.sizeof(_VM64) // 4)
        if _libc.host_statistics64(_HOST, 4, ctypes.byref(vm), ctypes.byref(cnt)) == 0:
            app = max(0, vm.internal - vm.purgeable) * PAGE
            wired = vm.wire * PAGE
            comp = vm.compressor * PAGE
            cached = (vm.external + vm.purgeable) * PAGE
            # mesma conta do Monitor de Atividade: memória usada = apps + fixa + comprimida
            d.update(app=app, wired=wired, compressed=comp, cached=cached, used=app + wired + comp,
                     free=vm.free * PAGE, swapins=vm.swapins, swapouts=vm.swapouts)
        sw = sysctl_raw("vm.swapusage")
        if sw and len(sw) >= 24:
            t, a, u = struct.unpack("<QQQ", sw[:24])
            d.update(swap_total=t, swap_used=u)
        lvl = sysctl_int("kern.memorystatus_vm_pressure_level")
        d["pressure"] = {1: "normal", 2: "alerta", 4: "crítica"}.get(lvl or 1, "normal")
    return d


def net_bytes() -> tuple[int, int]:
    rx = tx = 0
    seen = set()
    for line in run(["netstat", "-ibn"], 5).splitlines()[1:]:
        p = line.split()
        if len(p) < 10 or not p[2].startswith("<Link"):
            continue
        name = p[0].rstrip("*")
        # interfaces físicas e celular; túneis (utun, VPN) não entram para não contar duas vezes
        if name in seen or not re.match(r"^(en|pdp_ip)\d", name):
            continue
        seen.add(name)
        try:
            # Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll (Address pode faltar)
            nums = [x for x in p[3:] if re.fullmatch(r"\d+|-", x)]
            if len(nums) >= 7:
                rx += int(nums[2]); tx += int(nums[5])
        except ValueError:
            pass
    return rx, tx


def disk_io() -> tuple[int, int]:
    out = run(["ioreg", "-c", "IOBlockStorageDriver", "-r", "-w", "0"], 5)
    r = sum(int(x) for x in re.findall(r'"Bytes \(Read\)"=(\d+)', out))
    w = sum(int(x) for x in re.findall(r'"Bytes \(Write\)"=(\d+)', out))
    return r, w


def gpu_util() -> int | None:
    m = re.findall(r'"Device Utilization %"=(\d+)', run(["ioreg", "-r", "-d", "1", "-w", "0", "-c", "IOAccelerator"], 5))
    return max(int(x) for x in m) if m else None


def data_volume() -> str:
    return "/System/Volumes/Data" if os.path.isdir("/System/Volumes/Data") else "/"


def disk_usage(path: str | None = None) -> dict:
    st = os.statvfs(path or data_volume())
    total = st.f_blocks * st.f_frsize
    free = st.f_bavail * st.f_frsize
    return {"total": total, "free": free, "used": total - free}


def battery() -> dict | None:
    out = run(["ioreg", "-rn", "AppleSmartBattery", "-a"], 5)
    if not out.strip():
        return None
    try:
        arr = plistlib.loads(out.encode())
    except Exception:
        return None
    if not arr:
        return None
    b = arr[0]
    bd = b.get("BatteryData") or {}
    g = lambda k: b.get(k) if b.get(k) is not None else bd.get(k)
    cur, mx = g("AppleRawCurrentCapacity") or g("CurrentCapacity"), g("AppleRawMaxCapacity") or g("MaxCapacity")
    design = g("DesignCapacity")
    pct = None
    m = re.search(r"(\d+)%;\s*([^;]+);\s*([^\s]+)?", run(["pmset", "-g", "batt"], 5))
    if m:
        pct = int(m.group(1))
    elif cur and mx:
        pct = round(cur * 100 / mx)

    def s16(v):
        return v - (1 << 64) if isinstance(v, int) and v >= 1 << 63 else v

    amp, volt = s16(b.get("Amperage") or b.get("InstantAmperage") or 0), b.get("Voltage") or 0
    ad = b.get("AdapterDetails") or {}
    tte = b.get("TimeRemaining") or b.get("AvgTimeToEmpty")
    return {
        "percent": pct, "charging": bool(b.get("IsCharging")), "external": bool(b.get("ExternalConnected")),
        "full": bool(b.get("FullyCharged")), "cycles": b.get("CycleCount"),
        "health": round(mx * 100 / design) if mx and design and mx > 100 else None,
        "max_mah": mx if mx and mx > 100 else None, "design_mah": design,
        "temp_c": round(g("Temperature") / 100, 1) if g("Temperature") else None,
        "watts": round(volt * amp / 1e6, 1) if volt and amp else 0,
        "adapter_w": ad.get("Watts"), "minutes_left": tte if isinstance(tte, int) and 0 < tte < 6000 else None,
        "condition": (b.get("BatteryData") or {}).get("Condition") or ("Normal" if not b.get("PermanentFailureStatus") else "Revisar"),
    }


def parse_cputime(s: str) -> float:
    days = 0
    if "-" in s:
        d, s = s.split("-", 1); days = int(d)
    parts = [float(x) for x in s.split(":")]
    secs = 0.0
    for p in parts:
        secs = secs * 60 + p
    return days * 86400 + secs


_info_cache: dict = {}


def system_info() -> dict:
    if _info_cache:
        d = dict(_info_cache)
    else:
        hw = {}
        try:
            hw = json.loads(run(["system_profiler", "SPHardwareDataType", "-json"], 20) or "{}")["SPHardwareDataType"][0]
        except (ValueError, KeyError, IndexError):
            pass
        d = {
            "computer": run(["scutil", "--get", "ComputerName"]).strip() or socket.gethostname(),
            "host": socket.gethostname(), "user": getpass.getuser(), "home": str(HOME),
            "model": hw.get("machine_name") or sysctl_str("hw.model"), "model_id": hw.get("machine_model") or sysctl_str("hw.model"),
            "chip": hw.get("chip_type") or sysctl_str("machdep.cpu.brand_string"),
            "cores": os.cpu_count(), "p_cores": sysctl_int("hw.perflevel0.physicalcpu"), "e_cores": sysctl_int("hw.perflevel1.physicalcpu"),
            "memory": sysctl_int("hw.memsize"),
            "macos": run(["sw_vers", "-productVersion"]).strip() or platform.mac_ver()[0], "build": run(["sw_vers", "-buildVersion"]).strip(),
            "arch": platform.machine(), "python": sys.version.split()[0], "python_path": os.path.realpath(sys.executable),
            "agent": VERSION,
        }
        _info_cache.update(d)
    bt = sysctl_raw("kern.boottime")
    if bt:
        d["boot"] = struct.unpack("<q", bt[:8])[0]
    d["fda"] = has_full_disk_access()
    d["shell"] = bool(load_config().get("allow_shell"))
    return d


def has_full_disk_access() -> bool:
    for p in (HOME / "Library" / "Safari", HOME / "Library" / "Mail", HOME / ".Trash"):
        if p.exists():
            try:
                os.listdir(p)
                return True
            except PermissionError:
                return False
            except OSError:
                continue
    return True


class Sampler:
    """Amostrador contínuo: deltas de CPU, rede, disco e processos entre leituras."""

    def __init__(self):
        self.prev_cpu = cpu_ticks()
        self.prev_net = (now(), *net_bytes())
        self.prev_io = (now(), *disk_io())
        self.prev_ps: dict[int, tuple[float, float]] = {}
        self.cached: dict = {}
        self.t_slow = 0.0
        self.minute: list[dict] = []
        self.lock = threading.Lock()

    def cpu(self) -> dict:
        cur = cpu_ticks()
        out = {"total": 0, "user": 0, "system": 0, "cores": []}
        if not cur or not self.prev_cpu:
            self.prev_cpu = cur
            return out
        tu = ts = tt = 0
        for a, b in zip(self.prev_cpu, cur):
            du, dsy, di, dn = (b[k] - a[k] for k in range(4))
            t = du + dsy + di + dn
            out["cores"].append(round((du + dsy + dn) * 100 / t, 1) if t > 0 else 0)
            tu += du + dn; ts += dsy; tt += t
        self.prev_cpu = cur
        if tt:
            out.update(user=round(tu * 100 / tt, 1), system=round(ts * 100 / tt, 1), total=round((tu + ts) * 100 / tt, 1))
        return out

    def rates(self) -> dict:
        t, rx, tx = now(), *net_bytes()
        pt, prx, ptx = self.prev_net
        dtm = max(t - pt, 0.001)
        self.prev_net = (t, rx, tx)
        return {"rx": max(0, (rx - prx) / dtm), "tx": max(0, (tx - ptx) / dtm), "rx_total": rx, "tx_total": tx}

    def io(self) -> dict:
        t, r, w = now(), *disk_io()
        pt, pr, pw = self.prev_io
        dtm = max(t - pt, 0.001)
        self.prev_io = (t, r, w)
        return {"read": max(0, (r - pr) / dtm), "write": max(0, (w - pw) / dtm)}

    def processes(self, limit: int = 80) -> dict:
        out = run(["ps", "-Acxo", "pid=,ppid=,rss=,time=,user=,stat=,comm="], 8)
        t = now()
        rows, cur = [], {}
        mem_total = sysctl_int("hw.memsize") or 1
        threads = 0
        for line in out.splitlines():
            p = line.split(None, 6)
            if len(p) < 7:
                continue
            try:
                pid, ppid, rss = int(p[0]), int(p[1]), int(p[2]) * 1024
                ct = parse_cputime(p[3])
            except ValueError:
                continue
            cur[pid] = (t, ct)
            prev = self.prev_ps.get(pid)
            cpu = round(max(0.0, (ct - prev[1]) * 100 / max(t - prev[0], 0.001)), 1) if prev else 0.0
            rows.append({"pid": pid, "ppid": ppid, "name": p[6], "user": p[4], "state": p[5][:1], "cpu": cpu,
                         "mem": rss, "mem_pct": round(rss * 100 / mem_total, 1), "time": round(ct)})
        self.prev_ps = cur
        rows.sort(key=lambda r: (-r["cpu"], -r["mem"]))
        zombies = sum(1 for r in rows if r["state"] == "Z")
        if limit:  # os que mais usam CPU e os que mais usam memória, para ordenar por qualquer um dos dois
            top = rows[:limit]
            seen = {r["pid"] for r in top}
            top += [r for r in sorted(rows, key=lambda r: -r["mem"])[: limit // 2] if r["pid"] not in seen]
        else:
            top = rows
        return {"count": len(rows), "zombies": zombies, "list": top}

    def sample(self, procs: bool = False) -> dict:
        with self.lock:
            d = {"ts": now(), "cpu": self.cpu(), "load": [round(x, 2) for x in os.getloadavg()],
                 "mem": memory(), "net": self.rates(), "io": self.io(), "gpu": gpu_util()}
            if now() - self.t_slow > 15 or not self.cached:
                self.t_slow = now()
                self.cached = {"disk": disk_usage(), "battery": battery()}
            d.update(self.cached)
            bt = sysctl_raw("kern.boottime")
            d["uptime"] = round(now() - struct.unpack("<q", bt[:8])[0]) if bt else None
            if procs:
                d["procs"] = self.processes()
            self.minute.append({"cpu": d["cpu"]["total"], "mem": d["mem"].get("used", 0), "rx": d["net"]["rx"],
                                "tx": d["net"]["tx"], "gpu": d["gpu"] or 0})
            d["health"] = health_score(d)
            return d

    def minute_summary(self) -> dict | None:
        with self.lock:
            m, self.minute = self.minute, []
        if not m:
            return None
        n = len(m)
        b = self.cached.get("battery") or {}
        return {"ts": int(now()), "cpu": round(sum(x["cpu"] for x in m) / n, 1), "mem": int(sum(x["mem"] for x in m) / n),
                "rx": int(sum(x["rx"] for x in m) / n * 60), "tx": int(sum(x["tx"] for x in m) / n * 60),
                "gpu": round(sum(x["gpu"] for x in m) / n, 1), "disk_free": (self.cached.get("disk") or {}).get("free"),
                "bat": b.get("percent"), "watts": b.get("watts")}


def health_score(d: dict) -> dict:
    """Nota de 0 a 100 com os motivos: CPU, pressão de memória, disco, swap, bateria."""
    score, why = 100, []
    c = d["cpu"]["total"]
    if c > 85:
        score -= 20; why.append("CPU muito ocupada")
    elif c > 60:
        score -= 8
    pr = d["mem"].get("pressure")
    if pr == "crítica":
        score -= 30; why.append("memória em pressão crítica")
    elif pr == "alerta":
        score -= 15; why.append("memória sob pressão")
    disk = d.get("disk") or {}
    if disk.get("total"):
        fp = disk["free"] * 100 / disk["total"]
        if fp < 5:
            score -= 25; why.append("disco quase cheio")
        elif fp < 15:
            score -= 10; why.append("pouco espaço livre")
    if (d["mem"].get("swap_used") or 0) > 4e9:
        score -= 8; why.append("swap alto")
    b = d.get("battery") or {}
    if b.get("health") and b["health"] < 80:
        score -= 8; why.append("bateria desgastada")
    if (d.get("uptime") or 0) > 21 * 86400:
        score -= 4; why.append("muitos dias sem reiniciar")
    return {"score": max(0, score), "why": why}


# ======================================================================= caminhos e segurança
PROTECTED = [HOME / x for x in ("", "Library", "Desktop", "Documents", "Downloads", "Pictures", "Movies", "Music",
                                 "Applications", "Public", ".Trash", ".ssh", ".gnupg", "Library/Mobile Documents")]


def real(p: str | Path) -> Path:
    return Path(os.path.realpath(os.path.expanduser(str(p))))


def inside(p: Path, root: Path) -> bool:
    try:
        p.relative_to(root)
        return True
    except ValueError:
        return False


def check_user_path(p: str, allow_apps: bool = False) -> Path:
    """Caminho que pode ir para a Lixeira: dentro da pasta pessoal (ou /Applications para
    desinstalar, ou um disco externo), nunca uma pasta-base nem os arquivos do próprio agente."""
    if not isinstance(p, str) or not p.startswith(("/", "~")):
        raise Fail(f"caminho inválido: {p!r}")
    ap = Path(os.path.abspath(os.path.expanduser(p)))
    rp = real(ap.parent) / ap.name  # o próprio item pode ser link: vai o link, não o destino
    ok = inside(rp, HOME) or (allow_apps and (inside(rp, Path("/Applications")))) or \
        (inside(rp, Path("/Volumes")) and len(rp.parts) > 3)
    if not ok:
        raise Fail(f"fora das pastas permitidas: {p}")
    if rp in PROTECTED or rp == Path("/Applications") or inside(rp, SUPPORT) or rp == SUPPORT:
        raise Fail(f"pasta protegida: {p}")
    if rp.parent == HOME / "Library":
        raise Fail(f"pasta do sistema protegida: {p}")
    if not os.path.lexists(rp):
        raise Fail(f"não existe mais: {p}")
    return rp


def trash_dir_for(p: Path) -> Path:
    if inside(p, Path("/Volumes")) and len(p.parts) > 2:
        vol = Path(*p.parts[:3])
        if os.stat(vol).st_dev != os.stat(HOME).st_dev:
            d = vol / ".Trashes" / str(os.getuid())
            d.mkdir(parents=True, exist_ok=True)
            return d
    return HOME / ".Trash"


def unique(dest: Path) -> Path:
    if not os.path.lexists(dest):
        return dest
    stem, suf = (dest.stem, dest.suffix) if not dest.name.endswith(".app") else (dest.name[:-4], ".app")
    for i in range(2, 10000):
        c = dest.with_name(f"{stem} {i}{suf}")
        if not os.path.lexists(c):
            return c
    return dest.with_name(f"{stem} {uuid.uuid4().hex[:6]}{suf}")


def move_to_trash(paths: list[Path], ctx: Ctx | None = None) -> dict:
    moved, errors = [], []
    for i, p in enumerate(paths):
        if ctx:
            ctx.check(); ctx.progress({"done": i, "total": len(paths), "path": str(p)})
        try:
            td = trash_dir_for(p)
            dest = unique(td / p.name)
            try:
                os.rename(p, dest)
            except OSError as e:
                if e.errno not in (13, 1, 18):  # sem permissão / disco diferente: Finder resolve (pede senha se preciso)
                    raise
                r = run_full(["osascript", "-e", 'on run a', "-e", 'tell application "Finder" to delete (POSIX file (item 1 of a) as alias)',
                              "-e", "end run", str(p)], 120)
                if r[0] != 0:
                    raise OSError(r[2].strip() or "o Finder recusou")
                dest = None
            moved.append([str(p), str(dest) if dest else None])
        except Exception as e:
            errors.append({"path": str(p), "error": str(e)})
    return {"moved": moved, "errors": errors}


def remove_forever(p: Path, root: Path) -> tuple[bool, str]:
    """Apaga de vez um item de uma lista montada pelo agente, conferindo que ainda está dentro da raiz."""
    rp = real(p.parent) / p.name
    if not inside(rp, real(root)) or rp == real(root):
        return False, "fora da pasta de origem"
    errs = []
    try:
        if os.path.islink(rp) or not rp.is_dir():
            os.remove(rp)
        else:
            shutil.rmtree(rp, onerror=lambda f, pth, ei: errs.append(pth))
    except FileNotFoundError:
        return True, ""
    except OSError as e:
        return False, e.strerror or str(e)
    return (not errs), (f"{len(errs)} itens protegidos" if errs else "")


def du_children(path: Path, timeout: float = 120) -> dict[str, int]:
    """Tamanho (alocado) de cada filho direto, via du — muito mais rápido que andar pela árvore em Python."""
    out = run(["du", "-kx", "-d", "1", str(path)], timeout)  # pastas; arquivos soltos vêm do stat
    res = {}
    for line in out.splitlines():
        k, _, name = line.partition("\t")
        if name and k.isdigit():
            res[name] = int(k) * 1024
    return res


def du_total(path: Path, timeout: float = 300) -> int | None:
    out = run(["du", "-skx", str(path)], timeout)
    m = re.match(r"(\d+)", out)
    return int(m.group(1)) * 1024 if m else None


# ======================================================================= histórico (com desfazer)
_hist_lock = threading.Lock()


def history_add(op: str, summary: str, freed: int = 0, undo: dict | None = None, origin: str = "") -> dict:
    e = {"id": uuid.uuid4().hex[:10], "ts": now(), "op": op, "summary": summary, "freed": freed, "origin": origin}
    if undo:
        e["undo"] = undo
    with _hist_lock:
        SUPPORT.mkdir(parents=True, exist_ok=True)
        with open(HISTORY, "a", encoding="utf-8") as f:
            f.write(json.dumps(e, ensure_ascii=False) + "\n")
    return e


def history_all() -> list[dict]:
    out = []
    try:
        with open(HISTORY, encoding="utf-8") as f:
            for line in f:
                try:
                    out.append(json.loads(line))
                except ValueError:
                    pass
    except OSError:
        pass
    return out


def history_rewrite(items: list[dict]) -> None:
    with _hist_lock:
        tmp = HISTORY.with_suffix(".tmp")
        tmp.write_text("".join(json.dumps(e, ensure_ascii=False) + "\n" for e in items[-2000:]), encoding="utf-8")
        tmp.replace(HISTORY)


# ======================================================================= planos (o painel só escolhe dentro deles)
PLANS: dict[str, dict] = {}


def new_plan(kind: str, items: list[dict], **extra) -> str:
    for k in [k for k, v in PLANS.items() if now() - v["ts"] > 3600]:
        PLANS.pop(k, None)
    pid = uuid.uuid4().hex[:12]
    PLANS[pid] = {"kind": kind, "ts": now(), "items": {it["path"]: it for it in items}, **extra}
    return pid


def get_plan(pid: str, kind: str) -> dict:
    p = PLANS.get(str(pid))
    if not p or p["kind"] != kind:
        raise Fail("a lista expirou — analise de novo")
    return p


def pick(plan: dict, paths) -> list[dict]:
    if not isinstance(paths, list) or not paths:
        raise Fail("nada selecionado")
    out = []
    for p in paths:
        it = plan["items"].get(p)
        if not it:
            raise Fail("item fora da lista analisada")
        out.append(it)
    return out


# ======================================================================= comandos: monitoramento
SAMPLER: Sampler | None = None


def sampler() -> Sampler:
    global SAMPLER
    if SAMPLER is None:
        SAMPLER = Sampler()
        time.sleep(0.3)
    return SAMPLER


def c_info(a, ctx):
    return system_info()


def c_snapshot(a, ctx):
    return sampler().sample(procs=True)


def c_procs(a, ctx):
    s = sampler()
    s.processes(0)
    time.sleep(1)
    return s.processes(0 if a.get("all") else int(a.get("limit") or 200))


CRITICAL = {"kernel_task", "launchd", "WindowServer", "loginwindow", "logd", "opendirectoryd", "securityd",
            "coreaudiod", "mds", "Finder", "Dock", "SystemUIServer", "configd", "notifyd", "powerd"}


def c_kill(a, ctx):
    pid = int(a.get("pid", 0))
    if pid <= 1 or pid == os.getpid():
        raise Fail("esse processo não pode ser encerrado")
    name = run(["ps", "-co", "comm=", "-p", str(pid)]).strip()
    if not name:
        raise Fail("processo não encontrado")
    if name in CRITICAL and not a.get("force_system"):
        raise Fail(f"{name} é essencial ao macOS — não encerrado")
    try:
        os.kill(pid, signal.SIGKILL if a.get("force") else signal.SIGTERM)
    except PermissionError:
        raise Fail(f"{name} pertence a outro usuário (precisa de administrador)")
    except ProcessLookupError:
        raise Fail("processo já terminou")
    history_add("processo", f"{'Forçou' if a.get('force') else 'Pediu'} o encerramento de {name} ({pid})", origin=a.get("_origin", ""))
    return {"pid": pid, "name": name}


def c_energy(a, ctx):
    out = run(["top", "-l", "2", "-s", "1", "-n", "25", "-o", "power", "-stats", "pid,command,power,cpu"], 15)
    blocks = out.split("PID")
    rows = []
    if len(blocks) >= 3:
        for line in blocks[-1].splitlines()[1:]:
            m = re.match(r"\s*(\d+)\s+(.+?)\s+([\d.]+)\s+([\d.]+)\s*$", line)
            if m:
                rows.append({"pid": int(m.group(1)), "name": m.group(2).strip(), "power": float(m.group(3)), "cpu": float(m.group(4))})
    rows.sort(key=lambda r: -r["power"])
    return {"list": rows, "battery": battery(), "assertions": sleep_blockers()}


def sleep_blockers() -> list[dict]:
    out = run(["pmset", "-g", "assertions"], 5)
    res = []
    for m in re.finditer(r"pid (\d+)\(([^)]+)\): \[[^\]]+\] [\d:]+ (\w+) named: \"([^\"]*)\"", out):
        if m.group(3) in ("PreventUserIdleSystemSleep", "PreventUserIdleDisplaySleep", "PreventSystemSleep"):
            res.append({"pid": int(m.group(1)), "name": m.group(2), "type": m.group(3), "reason": m.group(4)})
    return res


def c_net(a, ctx):
    ifs = []
    out = run(["ifconfig"], 5)
    cur = None
    for line in out.splitlines():
        m = re.match(r"^(\w+): flags=\w+<([^>]*)>", line)
        if m:
            cur = {"name": m.group(1), "up": "UP" in m.group(2).split(","), "ipv4": [], "ipv6": [], "mac": None}
            ifs.append(cur)
        elif cur is not None:
            s = line.strip()
            if s.startswith("inet "):
                cur["ipv4"].append(s.split()[1])
            elif s.startswith("inet6 ") and not s.split()[1].startswith("fe80"):
                cur["ipv6"].append(s.split()[1].split("%")[0])
            elif s.startswith("ether "):
                cur["mac"] = s.split()[1]
            elif s.startswith("status:"):
                cur["active"] = s.endswith("active")
    ports = {}
    hw = run(["networksetup", "-listallhardwareports"], 5)
    for m in re.finditer(r"Hardware Port: (.+)\nDevice: (\w+)", hw):
        ports[m.group(2)] = m.group(1)
    for i in ifs:
        i["port"] = ports.get(i["name"])
    vpn = [i["name"] for i in ifs if i["name"].startswith(("utun", "ipsec", "ppp")) and i["ipv4"]]
    ifs = [i for i in ifs if (i["ipv4"] or i.get("port")) and not i["name"].startswith(("lo", "utun", "gif", "stf", "anpi", "ap", "awdl", "llw", "ipsec", "ppp"))]
    route = run(["route", "-n", "get", "default"], 5)
    gw = re.search(r"gateway: (\S+)", route)
    dev = re.search(r"interface: (\S+)", route)
    dns = re.findall(r"nameserver\[\d+\] : (\S+)", run(["scutil", "--dns"], 5))
    procs = []
    out = run(["nettop", "-P", "-d", "-L", "2", "-s", "1", "-n", "-x", "-J", "bytes_in,bytes_out"], 10)
    parts = out.split(",bytes_in,bytes_out,")
    if len(parts) >= 3:
        for line in parts[-1].splitlines():
            p = line.split(",")
            if len(p) >= 3 and p[0]:
                name, _, pid = p[0].rpartition(".")
                try:
                    rx, tx = int(p[1] or 0), int(p[2] or 0)
                except ValueError:
                    continue
                if rx or tx:
                    procs.append({"name": name, "pid": int(pid) if pid.isdigit() else None, "rx": rx, "tx": tx})
    procs.sort(key=lambda r: -(r["rx"] + r["tx"]))
    proxy = run(["scutil", "--proxy"], 5)
    return {"interfaces": ifs, "gateway": gw.group(1) if gw else None, "default_if": dev.group(1) if dev else None,
            "dns": list(dict.fromkeys(dns))[:6], "procs": procs[:40],
            "vpn": vpn, "proxy": bool(re.search(r"(HTTPEnable|HTTPSEnable|SOCKSEnable) : 1", proxy)), "listening": listening()}


def listening() -> list[dict]:
    out = run(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"], 10)
    res, pid, name = [], None, None
    for line in out.splitlines():
        if line.startswith("p"):
            pid = int(line[1:])
        elif line.startswith("c"):
            name = line[1:]
        elif line.startswith("n"):
            addr = line[1:]
            port = addr.rsplit(":", 1)[-1]
            if port.isdigit():
                res.append({"pid": pid, "name": name, "addr": addr, "port": int(port)})
    seen, out2 = set(), []
    for r in sorted(res, key=lambda r: r["port"]):
        k = (r["pid"], r["port"])
        if k not in seen:
            seen.add(k); out2.append(r)
    return out2[:80]


def c_battery(a, ctx):
    b = battery()
    if not b:
        return {"present": False}
    b["present"] = True
    b["assertions"] = sleep_blockers()
    b["settings"] = run(["pmset", "-g", "custom"], 5)[:4000]
    return b


# ======================================================================= armazenamento
def volumes() -> list[dict]:
    out = run(["df", "-kP"], 10)
    res = []
    for line in out.splitlines()[1:]:
        p = line.split(None, 5)
        if len(p) < 6 or not p[0].startswith("/dev/disk"):
            continue
        mnt = p[5]
        if mnt.startswith("/System/Volumes/") and mnt != "/System/Volumes/Data":
            continue
        if mnt == "/":
            continue  # o volume do sistema é selado; o espaço real é o do volume de dados
        total, used, avail = int(p[1]) * 1024, int(p[2]) * 1024, int(p[3]) * 1024
        res.append({"device": p[0], "mount": mnt, "name": "Macintosh HD" if mnt == "/System/Volumes/Data" else Path(mnt).name,
                    "total": total, "used": total - avail, "free": avail, "internal": not mnt.startswith("/Volumes/")})
    try:
        pl = plistlib.loads(run(["diskutil", "info", "-plist", "/"], 10).encode())
        for v in res:
            if v["internal"]:
                v["smart"] = pl.get("SMARTStatus")
                v["fs"] = pl.get("FilesystemName")
                v["purgeable_free"] = pl.get("APFSContainerFree")
    except Exception:
        pass
    return res


STORAGE_CATS = [
    ("apps", "Aplicativos", ["/Applications", str(HOME / "Applications")]),
    ("docs", "Documentos", [str(HOME / "Documents")]),
    ("desktop", "Mesa", [str(HOME / "Desktop")]),
    ("downloads", "Downloads", [str(HOME / "Downloads")]),
    ("photos", "Imagens e Fotos", [str(HOME / "Pictures")]),
    ("music", "Música", [str(HOME / "Music")]),
    ("movies", "Filmes", [str(HOME / "Movies")]),
    ("icloud", "iCloud Drive (local)", [str(HOME / "Library/Mobile Documents")]),
    ("dev", "Desenvolvedor", [str(HOME / "Library/Developer"), str(HOME / ".npm"), str(HOME / ".cargo"), str(HOME / ".gradle"),
                              str(HOME / "go"), str(HOME / ".rustup"), str(HOME / ".docker")]),
    ("mail", "Mail e Mensagens", [str(HOME / "Library/Mail"), str(HOME / "Library/Messages")]),
    ("caches", "Caches", [str(HOME / "Library/Caches")]),
    ("appdata", "Dados de apps", [str(HOME / "Library/Application Support"), str(HOME / "Library/Containers"), str(HOME / "Library/Group Containers")]),
    ("trash", "Lixeira", [str(HOME / ".Trash")]),
]


def c_storage(a, ctx):
    vol = disk_usage()
    cats = []

    def measure(cat):
        cid, label, paths = cat
        total, denied = 0, False
        for p in paths:
            if not os.path.exists(p):
                continue
            try:
                os.listdir(p)
            except PermissionError:
                denied = True
                continue
            except OSError:
                continue
            total += du_total(Path(p), 600) or 0
        return {"id": cid, "label": label, "size": total, "denied": denied, "paths": paths}

    with cf.ThreadPoolExecutor(5) as ex:
        futs = {ex.submit(measure, c): c for c in STORAGE_CATS}
        for f in cf.as_completed(futs):
            ctx.check()
            r = f.result()
            cats.append(r)
            ctx.progress({"cat": r, "done": len(cats), "total": len(STORAGE_CATS)}, force=True)
    order = [c[0] for c in STORAGE_CATS]
    cats.sort(key=lambda c: order.index(c["id"]))
    known = sum(c["size"] for c in cats)
    cats.append({"id": "system", "label": "macOS e outros", "size": max(0, vol["used"] - known), "denied": False, "paths": []})
    return {"volume": vol, "cats": cats, "volumes": volumes(), "fda": has_full_disk_access()}


def c_volumes(a, ctx):
    return volumes()


def c_analyze(a, ctx):
    path = real(a.get("path") or str(HOME))
    if not path.is_dir():
        raise Fail("pasta não encontrada")
    try:
        os.listdir(path)
    except PermissionError:
        raise Fail("sem permissão para ler essa pasta (Acesso Total ao Disco)")
    ctx.progress({"path": str(path)}, force=True)
    sizes = du_children(path, 900)
    total = sizes.pop(str(path), None) or sum(sizes.values())
    items = []
    with os.scandir(path) as it:
        for e in it:
            full = os.path.join(str(path), e.name)
            try:
                st = e.stat(follow_symlinks=False)
            except OSError:
                continue
            isdir = e.is_dir(follow_symlinks=False)
            items.append({"name": e.name, "path": full, "dir": isdir, "size": sizes.get(full, st.st_blocks * 512 if not isdir else 0),
                          "mtime": st.st_mtime, "link": e.is_symlink(), "pkg": isdir and e.name.endswith((".app", ".photoslibrary", ".bundle", ".pkg"))})
    items.sort(key=lambda x: -x["size"])
    return {"path": str(path), "parent": str(path.parent) if path != path.parent else None, "total": total, "items": items[:400],
            "count": len(items), "trashable": inside(path, HOME) or inside(path, Path("/Volumes"))}


def c_large(a, ctx):
    root = real(a.get("path") or str(HOME))
    min_b = int(float(a.get("min_mb") or 200) * 1e6)
    limit = min(int(a.get("limit") or 200), 1000)
    found = []
    out = run(["mdfind", "-onlyin", str(root), f"kMDItemFSSize >= {min_b}"], 60)
    paths = [p for p in out.splitlines() if p.strip()]
    if not paths:  # Spotlight desligado nessa pasta: anda pela árvore
        for dirpath, dirs, files in os.walk(root):
            ctx.check()
            dirs[:] = [d for d in dirs if not d.startswith(".") and not d.endswith((".app", ".photoslibrary"))]
            ctx.progress({"path": dirpath})
            for f in files:
                fp = os.path.join(dirpath, f)
                try:
                    if os.lstat(fp).st_size >= min_b:
                        paths.append(fp)
                except OSError:
                    pass
    for p in paths:
        try:
            st = os.lstat(p)
        except OSError:
            continue
        if not os.path.isfile(p) or os.path.islink(p) or st.st_size < min_b:
            continue
        if "/.Trash/" in p or "/Library/Developer/CoreSimulator/" in p:
            continue
        found.append({"path": p, "name": os.path.basename(p), "size": st.st_size, "alloc": st.st_blocks * 512,
                      "mtime": st.st_mtime, "atime": st.st_atime, "kind": kind_of(p)})
    found.sort(key=lambda x: -x["size"])
    found = found[:limit]
    return {"root": str(root), "min_mb": min_b / 1e6, "items": found, "plan": new_plan("trashable", found)}


def c_trash(a, ctx):
    """Move para a Lixeira caminhos escolhidos no painel (análise de disco, arquivos grandes)."""
    paths = [check_user_path(p) for p in (a.get("paths") or [])][:500]
    if not paths:
        raise Fail("nada selecionado")
    sizes = {str(p): (du_total(p, 60) or 0) for p in paths}
    r = move_to_trash(paths, ctx)
    freed = sum(sizes.get(m[0], 0) for m in r["moved"])
    if r["moved"]:
        e = history_add("lixeira", f"{len(r['moved'])} itens para a Lixeira", freed,
                        {"type": "trash", "moves": r["moved"]}, a.get("_origin", ""))
        r["history"] = e["id"]
    r["freed"] = freed
    return r


def c_reveal(a, ctx):
    p = real(a.get("path", ""))
    if not os.path.lexists(p):
        raise Fail("não existe")
    run(["open", "-R", str(p)])
    return {"ok": True}


def c_open_settings(a, ctx):
    panes = {"fda": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
             "storage": "x-apple.systempreferences:com.apple.settings.Storage",
             "battery": "x-apple.systempreferences:com.apple.Battery-Settings.extension",
             "login": "x-apple.systempreferences:com.apple.LoginItems-Settings.extension",
             "network": "x-apple.systempreferences:com.apple.Network-Settings.extension"}
    url = panes.get(a.get("pane"))
    if not url:
        raise Fail("painel desconhecido")
    run(["open", url])
    return {"ok": True}


KINDS = {
    "Imagens": "jpg jpeg png gif heic heif webp tiff tif bmp svg raw cr2 cr3 nef arw dng psd ai eps ico avif jxl",
    "Vídeos": "mp4 mov m4v avi mkv webm wmv flv mpg mpeg 3gp mts",
    "Áudio": "mp3 wav aac m4a flac aiff aif ogg opus mid midi caf",
    "Documentos": "pdf doc docx pages txt rtf md odt epub tex rtfd",
    "Planilhas": "xls xlsx numbers csv ods tsv",
    "Apresentações": "ppt pptx key odp",
    "Compactados": "zip rar 7z tar gz tgz bz2 xz zst",
    "Instaladores": "dmg pkg mpkg iso xip",
    "Código": "py js mjs ts tsx jsx html htm css scss json xml yml yaml toml sh zsh c cpp h hpp m swift java kt go rs rb php sql ipynb lua r",
    "Fontes": "ttf otf woff woff2",
    "3D e Design": "fig sketch xd blend obj stl glb gltf fbx 3mf usdz afdesign afphoto",
}
EXT_KIND = {e: k for k, v in KINDS.items() for e in v.split()}


def kind_of(p: str) -> str:
    return EXT_KIND.get(Path(p).suffix.lower().lstrip("."), "Outros")


# ======================================================================= limpeza
TMPDIR = Path(os.environ.get("TMPDIR") or "/tmp")
BROWSERS = {"com.apple.Safari": "Safari", "Google": "Chrome", "com.google.Chrome": "Chrome", "Firefox": "Firefox",
            "org.mozilla.firefox": "Firefox", "Microsoft Edge": "Edge", "com.microsoft.edgemac": "Edge",
            "BraveSoftware": "Brave", "com.brave.Browser": "Brave", "com.operasoftware.Opera": "Opera",
            "company.thebrowser.Browser": "Arc", "Arc": "Arc", "com.vivaldi.Vivaldi": "Vivaldi", "Vivaldi": "Vivaldi"}
DEV_CACHES = {"Homebrew", "pip", "pip-tools", "go-build", "Yarn", "yarn", "CocoaPods", "org.carthage.CarthageKit", "JetBrains",
              "node-gyp", "typescript", "ms-playwright", "pnpm", "deno", "bazel", "com.apple.dt.Xcode", "Cypress", "electron",
              "electron-builder", "puppeteer", "uv", "pypoetry", "composer", "swiftpm", "org.swift.swiftpm", "golangci-lint",
              "huggingface", "torch", "node", "bun", "gradle", "sccache", "zig", "ccache"}
CLEAN_CATS = [
    # id, rótulo, descrição, marcado por padrão
    ("apps", "Caches de apps", "Arquivos temporários que os apps recriam quando precisam.", True),
    ("browsers", "Caches de navegadores", "Safari, Chrome, Firefox, Edge, Brave, Arc… (histórico, senhas e abas ficam).", True),
    ("logs", "Logs e relatórios de falha", "Registros antigos de apps e do sistema do usuário.", True),
    ("tmp", "Temporários antigos", "Arquivos temporários com mais de 3 dias.", True),
    ("trash", "Lixeira", "Itens já enviados para a Lixeira.", True),
    ("mail", "Anexos baixados do Mail", "Cópias de anexos abertos — os e-mails continuam com eles.", True),
    ("ios_updates", "Atualizações de iPhone/iPad", "Instaladores de iOS baixados pelo Finder.", True),
    ("dev", "Caches de desenvolvimento", "Xcode DerivedData, simuladores, npm, Homebrew, pip, Go, Gradle… (baixa de novo quando precisar).", False),
    ("ios_backups", "Backups de iPhone/iPad", "Cópias de segurança locais — apague só se tiver outra cópia.", False),
    ("saved_state", "Estado salvo das janelas", "Janelas que os apps reabrem ao iniciar.", False),
]


def clean_roots() -> dict[str, list[tuple[Path, callable]]]:
    lc = HOME / "Library" / "Caches"
    dev_extra = [HOME / "Library/Developer/Xcode/DerivedData", HOME / "Library/Developer/Xcode/iOS DeviceSupport",
                 HOME / "Library/Developer/Xcode/watchOS DeviceSupport", HOME / "Library/Developer/CoreSimulator/Caches",
                 HOME / ".npm/_cacache", HOME / ".gradle/caches", HOME / ".cargo/registry/cache", HOME / "Library/pnpm/store",
                 HOME / ".cache/huggingface/xet"]
    old = now() - 3 * 86400
    return {
        "apps": [(lc, lambda n, st: n not in BROWSERS and n not in DEV_CACHES and not n.startswith("com.gplansis"))],
        "browsers": [(lc, lambda n, st: n in BROWSERS)],
        "dev": [(lc, lambda n, st: n in DEV_CACHES)] + [(p, None) for p in dev_extra],
        "logs": [(HOME / "Library/Logs", None)],
        "tmp": [(TMPDIR, lambda n, st: st.st_mtime < old and st.st_uid == os.getuid())],
        "trash": [(HOME / ".Trash", None)],
        "mail": [(HOME / "Library/Containers/com.apple.mail/Data/Library/Mail Downloads", None)],
        "ios_updates": [(HOME / "Library/iTunes/iPhone Software Updates", None), (HOME / "Library/iTunes/iPad Software Updates", None)],
        "ios_backups": [(HOME / "Library/Application Support/MobileSync/Backup", None)],
        "saved_state": [(HOME / "Library/Saved Application State", None)],
    }


def whitelist() -> list[str]:
    return [str(real(p)) for p in load_config().get("whitelist", [])]


def c_clean_scan(a, ctx):
    roots = clean_roots()
    wl = whitelist()
    cats, items, denied = [], [], []
    meta = {c[0]: c for c in CLEAN_CATS}
    for ci, (cid, entries) in enumerate(roots.items()):
        ctx.check()
        ctx.progress({"cat": meta[cid][1], "done": ci, "total": len(roots)}, force=True)
        cat_items = []
        for root, filt in entries:
            if not root.exists():
                continue
            try:
                names = os.listdir(root)
            except PermissionError:
                denied.append(cid)
                continue
            except OSError:
                continue
            sizes = du_children(root, 600)
            for n in names:
                p = root / n
                if n in (".DS_Store", ".localized"):
                    continue
                try:
                    st = os.lstat(p)
                except OSError:
                    continue
                if filt and not filt(n, st):
                    continue
                if any(str(p) == w or str(p).startswith(w + "/") for w in wl):
                    continue
                size = sizes.get(str(p), st.st_blocks * 512)
                if size <= 0:
                    continue
                label = BROWSERS.get(n, n) if cid == "browsers" else n
                cat_items.append({"path": str(p), "name": label, "size": size, "cat": cid, "root": str(root), "mtime": st.st_mtime})
        cat_items.sort(key=lambda x: -x["size"])
        items += cat_items
        c = meta[cid]
        cats.append({"id": cid, "label": c[1], "desc": c[2], "default": c[3], "size": sum(i["size"] for i in cat_items),
                     "count": len(cat_items), "items": cat_items[:300], "denied": cid in denied})
    return {"cats": cats, "plan": new_plan("clean", items), "total": sum(c["size"] for c in cats if c["default"]),
            "fda": has_full_disk_access(), "whitelist": load_config().get("whitelist", [])}


def c_clean_apply(a, ctx):
    plan = get_plan(a.get("plan"), "clean")
    chosen = pick(plan, a.get("paths"))
    freed, fails, n = 0, [], 0
    trash_items = [it for it in chosen if it["cat"] == "trash"]
    for i, it in enumerate(chosen):
        ctx.check()
        if it["cat"] == "trash":
            continue
        ctx.progress({"done": i, "total": len(chosen), "name": it["name"], "freed": freed})
        ok, err = remove_forever(Path(it["path"]), Path(it["root"]))
        if ok or err.endswith("protegidos"):
            freed += it["size"] if ok else 0
            n += 1
        if not ok:
            fails.append({"path": it["path"], "error": err})
    if trash_items:
        r = run_full(["osascript", "-e", 'tell application "Finder" to empty trash'], 300)
        if r[0] == 0:
            freed += sum(t["size"] for t in trash_items); n += len(trash_items)
        else:
            for t in trash_items:
                ok, err = remove_forever(Path(t["path"]), Path(t["root"]))
                if ok:
                    freed += t["size"]; n += 1
                else:
                    fails.append({"path": t["path"], "error": err})
    PLANS.pop(a.get("plan"), None)
    history_add("limpeza", f"Limpeza: {n} itens", freed, origin=a.get("_origin", ""))
    return {"freed": freed, "count": n, "errors": fails[:50], "error_count": len(fails), "disk": disk_usage()}


def c_trash_empty(a, ctx):
    r = run_full(["osascript", "-e", 'tell application "Finder" to empty trash'], 300)
    if r[0] != 0:
        raise Fail(r[2].strip() or "o Finder não esvaziou a Lixeira")
    history_add("lixeira", "Lixeira esvaziada", origin=a.get("_origin", ""))
    return {"ok": True, "disk": disk_usage()}


def c_whitelist(a, ctx):
    cfg = load_config()
    wl = cfg.get("whitelist", [])
    if a.get("add"):
        p = str(real(a["add"]))
        if p not in wl:
            wl.append(p)
    if a.get("remove"):
        wl = [w for w in wl if w != a["remove"]]
    cfg["whitelist"] = wl
    save_config(cfg)
    return {"whitelist": wl}


# ======================================================================= purgar (artefatos de projetos), instaladores, duplicados
PURGE_TARGETS = {"node_modules": ["package.json"], "target": ["Cargo.toml", "pom.xml"], ".build": ["Package.swift"],
                 "build": ["package.json", "build.gradle", "build.gradle.kts", "CMakeLists.txt", "setup.py", "pyproject.toml"],
                 "dist": ["package.json", "setup.py", "pyproject.toml"], ".next": ["package.json"], ".nuxt": ["package.json"],
                 ".turbo": ["package.json"], ".parcel-cache": ["package.json"], ".svelte-kit": ["package.json"],
                 "Pods": ["Podfile"], ".gradle": ["build.gradle", "build.gradle.kts", "settings.gradle"], "__pycache__": [],
                 ".pytest_cache": [], ".mypy_cache": [], ".ruff_cache": [], "DerivedData": [], ".dart_tool": ["pubspec.yaml"],
                 "vendor": ["composer.json"], ".angular": ["angular.json"]}
PURGE_ROOTS = ["Projects", "Projetos", "GitHub", "Github", "dev", "Developer", "code", "Code", "src", "repos", "workspace", "Documents/GitHub"]


def c_purge_scan(a, ctx):
    roots = [real(r) for r in (a.get("roots") or [])] or [HOME / r for r in PURGE_ROOTS if (HOME / r).is_dir()]
    roots = [r for r in roots if inside(r, HOME) or inside(r, Path("/Volumes"))]
    if not roots:
        raise Fail("nenhuma pasta de projetos encontrada — informe uma (ex.: purgar ~/Projetos)")
    found = []
    recent = now() - 7 * 86400
    for root in roots:
        base = len(root.parts)
        for dirpath, dirs, files in os.walk(root):
            ctx.check()
            ctx.progress({"path": dirpath, "found": len(found)})
            depth = len(Path(dirpath).parts) - base
            keep = []
            for d in dirs:
                if d in PURGE_TARGETS:
                    need = PURGE_TARGETS[d]
                    if not need or any(os.path.exists(os.path.join(dirpath, n)) for n in need):
                        if d in ("vendor", "build", "dist") and os.path.exists(os.path.join(dirpath, d, ".git")):
                            continue
                        found.append(os.path.join(dirpath, d))
                        continue
                if d.startswith(".") and d not in (".github",) or d.endswith((".app", ".xcodeproj")):
                    continue
                keep.append(d)
            dirs[:] = keep if depth < 6 else []
    items = []
    for i, p in enumerate(found):
        ctx.check()
        ctx.progress({"measure": i, "total": len(found)})
        size = du_total(Path(p), 300) or 0
        proj = os.path.dirname(p)
        try:
            active = max(os.stat(proj).st_mtime, os.stat(p).st_mtime) > recent
        except OSError:
            active = True
        if size > 0:
            items.append({"path": p, "name": os.path.basename(p), "project": proj, "size": size, "recent": active,
                          "root": str(next((r for r in roots if inside(Path(p), r)), Path(proj)))})
    items.sort(key=lambda x: -x["size"])
    return {"roots": [str(r) for r in roots], "items": items, "plan": new_plan("purge", items), "total": sum(i["size"] for i in items)}


def c_purge_apply(a, ctx):
    plan = get_plan(a.get("plan"), "purge")
    chosen = pick(plan, a.get("paths"))
    freed, fails = 0, []
    for i, it in enumerate(chosen):
        ctx.check()
        ctx.progress({"done": i, "total": len(chosen), "name": it["path"]})
        ok, err = remove_forever(Path(it["path"]), Path(it["project"]))
        if ok:
            freed += it["size"]
        else:
            fails.append({"path": it["path"], "error": err})
    history_add("purgar", f"Artefatos de projetos: {len(chosen) - len(fails)} pastas", freed, origin=a.get("_origin", ""))
    return {"freed": freed, "errors": fails}


def c_installers(a, ctx):
    items = []
    exts = (".dmg", ".pkg", ".mpkg", ".iso", ".xip")
    for root in (HOME / "Downloads", HOME / "Desktop", HOME / "Documents", HOME / "Library/Caches/Homebrew/downloads"):
        if not root.is_dir():
            continue
        base = len(root.parts)
        for dirpath, dirs, files in os.walk(root):
            ctx.check()
            dirs[:] = [d for d in dirs if not d.startswith(".") and not d.endswith((".app", ".pkg")) and len(Path(dirpath).parts) - base < 3]
            for f in files + [d for d in os.listdir(dirpath) if d.endswith(".pkg") and os.path.isdir(os.path.join(dirpath, d))]:
                if f.lower().endswith(exts):
                    p = os.path.join(dirpath, f)
                    try:
                        st = os.lstat(p)
                    except OSError:
                        continue
                    items.append({"path": p, "name": f, "size": du_total(Path(p), 60) or st.st_size, "mtime": st.st_mtime,
                                  "where": str(root).replace(str(HOME), "~")})
    items.sort(key=lambda x: -x["size"])
    return {"items": items, "plan": new_plan("trashable", items), "total": sum(i["size"] for i in items)}


def c_plan_trash(a, ctx):
    """Lixeira para itens de uma lista do agente (instaladores, grandes, duplicados)."""
    plan = get_plan(a.get("plan"), a.get("kind") or "trashable")
    chosen = pick(plan, a.get("paths"))
    paths = [check_user_path(it["path"]) for it in chosen]
    r = move_to_trash(paths, ctx)
    sizes = {it["path"]: it.get("size", 0) for it in chosen}
    freed = sum(sizes.get(m[0], 0) for m in r["moved"])
    if r["moved"]:
        label = {"dupes": "duplicados", "trashable": "itens"}.get(plan["kind"], "itens")
        e = history_add("lixeira", f"{len(r['moved'])} {label} para a Lixeira", freed, {"type": "trash", "moves": r["moved"]}, a.get("_origin", ""))
        r["history"] = e["id"]
    r["freed"] = freed
    return r


SKIP_DIRS = {".git", "node_modules", ".Trash", "Library", ".npm", ".cache", ".venv", "venv", "__pycache__"}


def c_dupes(a, ctx):
    root = real(a.get("path") or str(HOME / "Downloads"))
    if not root.is_dir():
        raise Fail("pasta não encontrada")
    min_b = int(float(a.get("min_kb") or 100) * 1024)
    by_size: dict[int, list[str]] = {}
    count = 0
    for dirpath, dirs, files in os.walk(root):
        ctx.check()
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".") and not d.endswith((".app", ".photoslibrary", ".bundle", ".framework"))]
        for f in files:
            if f.startswith("."):
                continue
            p = os.path.join(dirpath, f)
            try:
                st = os.lstat(p)
            except OSError:
                continue
            if not os.path.isfile(p) or os.path.islink(p) or st.st_size < min_b:
                continue
            by_size.setdefault(st.st_size, []).append(p)
            count += 1
            if count % 500 == 0:
                ctx.progress({"phase": "listando", "files": count, "path": dirpath})
            if count > 300_000:
                break
    cands = [v for v in by_size.values() if len(v) > 1]

    def h(p, full):
        d = hashlib.blake2b(digest_size=20)
        with open(p, "rb") as f:
            if full:
                for b in iter(lambda: f.read(1 << 20), b""):
                    d.update(b)
            else:
                d.update(f.read(65536))
        return d.hexdigest()

    groups = []
    for gi, ps in enumerate(cands):
        ctx.check()
        ctx.progress({"phase": "comparando", "done": gi, "total": len(cands)})
        stage: dict[str, list[str]] = {}
        for p in ps:
            try:
                stage.setdefault(h(p, False), []).append(p)
            except OSError:
                pass
        for v in stage.values():
            if len(v) < 2:
                continue
            fullg: dict[str, list[str]] = {}
            for p in v:
                try:
                    fullg.setdefault(h(p, True), []).append(p)
                except OSError:
                    pass
            for hh, g in fullg.items():
                if len(g) > 1:
                    files = []
                    for p in g:
                        st = os.lstat(p)
                        files.append({"path": p, "mtime": st.st_mtime, "birth": getattr(st, "st_birthtime", st.st_mtime)})
                    files.sort(key=lambda x: x["birth"])
                    size = os.lstat(g[0]).st_size
                    groups.append({"hash": hh[:12], "size": size, "name": os.path.basename(files[0]["path"]), "files": files,
                                   "waste": size * (len(g) - 1)})
    groups.sort(key=lambda g: -g["waste"])
    items = [{"path": f["path"], "size": g["size"]} for g in groups for f in g["files"]]
    return {"root": str(root), "groups": groups[:500], "files": count, "waste": sum(g["waste"] for g in groups),
            "plan": new_plan("dupes", items)}


# ======================================================================= aplicativos e desinstalação
_app_size: dict[str, tuple[float, int]] = {}


def app_dirs() -> list[Path]:
    res = []
    for base in (Path("/Applications"), HOME / "Applications"):
        if not base.is_dir():
            continue
        for e in sorted(base.iterdir()):
            if e.suffix == ".app":
                res.append(e)
            elif e.is_dir() and not e.name.startswith("."):
                res += [x for x in e.iterdir() if x.suffix == ".app"]
    return res


def app_info(p: Path) -> dict:
    info = {}
    try:
        with open(p / "Contents" / "Info.plist", "rb") as f:
            info = plistlib.load(f)
    except Exception:
        pass
    bid = info.get("CFBundleIdentifier") or ""
    return {"path": str(p), "name": info.get("CFBundleDisplayName") or info.get("CFBundleName") or p.stem, "file": p.name,
            "bundle_id": bid, "version": info.get("CFBundleShortVersionString") or info.get("CFBundleVersion") or "",
            "apple": bid.startswith("com.apple."), "appstore": (p / "Contents" / "_MASReceipt").exists(),
            "arch": None}


def c_apps(a, ctx):
    apps = [app_info(p) for p in app_dirs()]
    running = run(["ps", "-Axo", "comm="], 5)
    with cf.ThreadPoolExecutor(6) as ex:
        def size(ap):
            p = ap["path"]
            mt = os.stat(p).st_mtime
            c = _app_size.get(p)
            if c and c[0] == mt:
                return c[1]
            s = du_total(Path(p), 120) or 0
            _app_size[p] = (mt, s)
            return s
        futs = {ex.submit(size, ap): ap for ap in apps}
        for i, f in enumerate(cf.as_completed(futs)):
            ctx.check()
            futs[f]["size"] = f.result()
            ctx.progress({"done": i, "total": len(apps)})
    paths = [ap["path"] for ap in apps]
    used = run(["mdls", "-name", "kMDItemLastUsedDate", "-raw"] + paths, 30).split("\0") if paths else []
    for ap, u in zip(apps, used):
        ap["last_used"] = None
        if u and u != "(null)":
            try:
                ap["last_used"] = dt.datetime.strptime(u.strip()[:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=dt.timezone.utc).timestamp()
            except ValueError:
                pass
        ap["running"] = (ap["path"] + "/Contents/MacOS/") in running
    apps.sort(key=lambda x: -x.get("size", 0))
    return {"apps": apps, "total": sum(x.get("size", 0) for x in apps)}


LEFTOVER_DIRS = [
    ("Application Support", "dados", False), ("Containers", "dados", False), ("Group Containers", "dados", False),
    ("Caches", "cache", True), ("Preferences", "preferências", True), ("Saved Application State", "estado", True),
    ("Logs", "logs", True), ("HTTPStorages", "cache", True), ("WebKit", "cache", True), ("Cookies", "cookies", True),
    ("LaunchAgents", "início automático", True), ("Application Scripts", "scripts", True), ("Preferences/ByHost", "preferências", True),
]


def c_leftovers(a, ctx):
    p = check_user_path(a.get("path", ""), allow_apps=True)
    if p.suffix != ".app":
        raise Fail("escolha um aplicativo (.app)")
    ap = app_info(p)
    if ap["apple"]:
        raise Fail("apps da Apple fazem parte do macOS e não são removidos")
    bid, name = ap["bundle_id"], ap["name"]
    keys = {k.lower() for k in (bid, name, p.stem) if k and len(k) >= 3}
    items = [{"path": str(p), "name": p.name, "kind": "aplicativo", "size": du_total(p, 120) or 0, "default": True, "root": str(p.parent)}]
    lib = HOME / "Library"
    for sub, kind, default in LEFTOVER_DIRS:
        d = lib / sub
        try:
            names = os.listdir(d)
        except OSError:
            continue
        for n in names:
            ln = n.lower()
            base = re.sub(r"\.(plist|savedstate|binarycookies|lockfile)$", "", ln)
            hit = base in keys or (bid and (ln.startswith(bid.lower() + ".") or base.endswith("." + bid.lower())))
            if hit:
                fp = d / n
                items.append({"path": str(fp), "name": f"{sub}/{n}", "kind": kind, "size": du_total(fp, 60) or 0, "default": default,
                              "root": str(d)})
    running = (str(p) + "/Contents/MacOS/") in run(["ps", "-Axo", "comm="], 5)
    return {"app": ap, "items": items, "running": running, "plan": new_plan("uninstall", items, app=ap),
            "total": sum(i["size"] for i in items)}


def c_uninstall(a, ctx):
    plan = get_plan(a.get("plan"), "uninstall")
    chosen = pick(plan, a.get("paths"))
    ap = plan["app"]
    if (ap["path"] + "/Contents/MacOS/") in run(["ps", "-Axo", "comm="], 5):
        raise Fail(f"feche o {ap['name']} antes de desinstalar")
    for it in chosen:  # LaunchAgent do app: descarrega antes de mover
        if "/LaunchAgents/" in it["path"]:
            run(["launchctl", "bootout", f"gui/{os.getuid()}", it["path"]], 10)
    paths = [check_user_path(it["path"], allow_apps=True) for it in chosen]
    r = move_to_trash(paths, ctx)
    freed = sum(it["size"] for it in chosen if any(m[0] == it["path"] for m in r["moved"]))
    if r["moved"]:
        e = history_add("desinstalar", f"{ap['name']} desinstalado ({len(r['moved'])} itens)", freed,
                        {"type": "trash", "moves": r["moved"]}, a.get("_origin", ""))
        r["history"] = e["id"]
    r["freed"] = freed
    return r


# ======================================================================= itens de início
def c_startup(a, ctx):
    uid = os.getuid()
    loaded = {}
    for line in run(["launchctl", "list"], 10).splitlines()[1:]:
        p = line.split("\t")
        if len(p) == 3:
            loaded[p[2]] = p[0] if p[0] != "-" else None
    disabled = set(re.findall(r'"([^"]+)" => (?:true|disabled)', run(["launchctl", "print-disabled", f"gui/{uid}"], 10)))
    items = []
    for d, scope in ((HOME / "Library/LaunchAgents", "usuário"), (Path("/Library/LaunchAgents"), "todos (agente)"),
                     (Path("/Library/LaunchDaemons"), "sistema (serviço)")):
        try:
            names = sorted(os.listdir(d))
        except OSError:
            continue
        for n in names:
            if not n.endswith(".plist"):
                continue
            fp = d / n
            try:
                with open(fp, "rb") as f:
                    pl = plistlib.load(f)
            except Exception:
                pl = {}
            label = pl.get("Label") or n[:-6]
            prog = pl.get("Program") or (pl.get("ProgramArguments") or [""])[0]
            items.append({"label": label, "path": str(fp), "scope": scope, "program": prog,
                          "run_at_load": bool(pl.get("RunAtLoad")), "keep_alive": bool(pl.get("KeepAlive")),
                          "loaded": label in loaded, "pid": loaded.get(label), "disabled": label in disabled,
                          "editable": scope == "usuário" and label != LABEL, "self": label == LABEL})
    return {"items": items, "note": "Itens de início modernos (Ajustes › Geral › Itens de Início) são geridos pelo macOS."}


def c_startup_toggle(a, ctx):
    label = str(a.get("label", ""))
    on = bool(a.get("on"))
    items = {i["label"]: i for i in c_startup({}, ctx)["items"]}
    it = items.get(label)
    if not it or not it["editable"]:
        raise Fail("só itens do seu usuário podem ser ligados ou desligados aqui")
    dom = f"gui/{os.getuid()}"
    if on:
        run(["launchctl", "enable", f"{dom}/{label}"]); run(["launchctl", "bootstrap", dom, it["path"]])
    else:
        run(["launchctl", "bootout", dom, it["path"]]); run(["launchctl", "disable", f"{dom}/{label}"])
    history_add("início", f"{'Ligou' if on else 'Desligou'} {label}", undo={"type": "startup", "label": label, "on": not on},
                origin=a.get("_origin", ""))
    return {"label": label, "on": on}


# ======================================================================= ajustes do macOS (defaults)
RESTART = {"finder": ["killall", "Finder"], "dock": ["killall", "Dock"], "ui": ["killall", "SystemUIServer"]}
SETTINGS = [
    # id, grupo, rótulo, descrição, domínio, chave, tipo, padrão, reinicia, opções
    ("finder.hidden", "Finder", "Mostrar arquivos ocultos", "Exibe arquivos que começam com ponto (atalho: ⌘⇧.).", "com.apple.finder", "AppleShowAllFiles", "bool", False, "finder", None),
    ("finder.ext", "Finder", "Mostrar todas as extensões", "Sempre mostra .pdf, .jpg etc. no nome.", "NSGlobalDomain", "AppleShowAllExtensions", "bool", False, "finder", None),
    ("finder.pathbar", "Finder", "Barra de caminho", "Mostra o caminho da pasta no rodapé das janelas.", "com.apple.finder", "ShowPathbar", "bool", False, "finder", None),
    ("finder.statusbar", "Finder", "Barra de estado", "Mostra itens e espaço livre no rodapé.", "com.apple.finder", "ShowStatusBar", "bool", False, "finder", None),
    ("finder.posix", "Finder", "Caminho completo no título", "Mostra /Users/… no título da janela.", "com.apple.finder", "_FXShowPosixPathInTitle", "bool", False, "finder", None),
    ("finder.folders", "Finder", "Pastas primeiro", "Ordena pastas antes dos arquivos.", "com.apple.finder", "_FXSortFoldersFirst", "bool", False, "finder", None),
    ("finder.extwarn", "Finder", "Avisar ao mudar extensão", "Pergunta antes de trocar a extensão de um arquivo.", "com.apple.finder", "FXEnableExtensionChangeWarning", "bool", True, "finder", None),
    ("finder.scope", "Finder", "Buscar na pasta atual", "A busca do Finder começa na pasta aberta, não no Mac inteiro.", "com.apple.finder", "FXDefaultSearchScope", "string", "SCev", "finder", [["SCev", "Este Mac"], ["SCcf", "Pasta atual"], ["SCsp", "Busca anterior"]]),
    ("finder.view", "Finder", "Visualização padrão", "Como as janelas novas abrem.", "com.apple.finder", "FXPreferredViewStyle", "string", "icnv", "finder", [["icnv", "Ícones"], ["Nlsv", "Lista"], ["clmv", "Colunas"], ["glyv", "Galeria"]]),
    ("finder.dsnet", "Finder", "Sem .DS_Store em rede", "Não grava arquivos .DS_Store em discos de rede.", "com.apple.desktopservices", "DSDontWriteNetworkStores", "bool", False, None, None),
    ("finder.dsusb", "Finder", "Sem .DS_Store em USB", "Não grava arquivos .DS_Store em pendrives e discos externos.", "com.apple.desktopservices", "DSDontWriteUSBStores", "bool", False, None, None),
    ("finder.desktop", "Finder", "Ícones na mesa", "Mostra arquivos e discos na mesa.", "com.apple.finder", "CreateDesktop", "bool", True, "finder", None),
    ("dock.autohide", "Dock", "Ocultar o Dock automaticamente", "O Dock aparece só quando o ponteiro chega na borda.", "com.apple.dock", "autohide", "bool", False, "dock", None),
    ("dock.delay", "Dock", "Atraso para mostrar o Dock", "Segundos até o Dock oculto aparecer (0 = imediato).", "com.apple.dock", "autohide-delay", "float", 0.5, "dock", [[0, "0 s"], [0.15, "0,15 s"], [0.5, "0,5 s"], [1, "1 s"]]),
    ("dock.size", "Dock", "Tamanho dos ícones", "Tamanho em pixels.", "com.apple.dock", "tilesize", "int", 48, "dock", [[32, "Pequeno"], [48, "Médio"], [64, "Grande"], [80, "Enorme"]]),
    ("dock.magnify", "Dock", "Ampliação", "Aumenta os ícones sob o ponteiro.", "com.apple.dock", "magnification", "bool", False, "dock", None),
    ("dock.recents", "Dock", "Apps recentes no Dock", "Mostra apps usados recentemente.", "com.apple.dock", "show-recents", "bool", True, "dock", None),
    ("dock.position", "Dock", "Posição", "Borda da tela onde o Dock fica.", "com.apple.dock", "orientation", "string", "bottom", "dock", [["left", "Esquerda"], ["bottom", "Embaixo"], ["right", "Direita"]]),
    ("dock.effect", "Dock", "Efeito ao minimizar", "Animação das janelas indo para o Dock.", "com.apple.dock", "mineffect", "string", "genie", "dock", [["genie", "Gênio"], ["scale", "Escala"], ["suck", "Sucção"]]),
    ("dock.hiddenapps", "Dock", "Apps ocultos translúcidos", "Ícones de apps ocultos (⌘H) ficam esmaecidos.", "com.apple.dock", "showhidden", "bool", False, "dock", None),
    ("dock.spaces", "Mission Control", "Reorganizar Espaços pelo uso", "O macOS reordena as mesas conforme o uso recente.", "com.apple.dock", "mru-spaces", "bool", True, "dock", None),
    ("shot.location", "Capturas de tela", "Salvar capturas em", "Pasta das capturas (⌘⇧3 / ⌘⇧4).", "com.apple.screencapture", "location", "string", "~/Desktop", "ui", [["~/Desktop", "Mesa"], ["~/Downloads", "Downloads"], ["~/Pictures/Capturas", "Imagens/Capturas"], ["~/Documents", "Documentos"]]),
    ("shot.type", "Capturas de tela", "Formato", "Formato do arquivo das capturas.", "com.apple.screencapture", "type", "string", "png", "ui", [["png", "PNG"], ["jpg", "JPEG"], ["heic", "HEIC"], ["pdf", "PDF"]]),
    ("shot.shadow", "Capturas de tela", "Sombra nas capturas de janela", "Inclui a sombra ao capturar uma janela.", "com.apple.screencapture", "disable-shadow", "invbool", True, "ui", None),
    ("shot.thumb", "Capturas de tela", "Miniatura flutuante", "Mostra a prévia no canto depois de capturar.", "com.apple.screencapture", "show-thumbnail", "bool", True, "ui", None),
    ("kb.repeat", "Teclado e texto", "Velocidade de repetição", "Quanto menor, mais rápido repete a tecla segurada.", "NSGlobalDomain", "KeyRepeat", "int", 6, None, [[2, "Muito rápida"], [6, "Normal"], [15, "Lenta"]]),
    ("kb.delay", "Teclado e texto", "Atraso até repetir", "Tempo segurando a tecla até começar a repetir.", "NSGlobalDomain", "InitialKeyRepeat", "int", 25, None, [[15, "Curto"], [25, "Normal"], [68, "Longo"]]),
    ("kb.hold", "Teclado e texto", "Acentos ao segurar a tecla", "Segurar uma letra mostra os acentos (desligado = repete a letra).", "NSGlobalDomain", "ApplePressAndHoldEnabled", "bool", True, None, None),
    ("kb.autocorrect", "Teclado e texto", "Correção automática", "Corrige a ortografia enquanto digita.", "NSGlobalDomain", "NSAutomaticSpellingCorrectionEnabled", "bool", True, None, None),
    ("kb.quotes", "Teclado e texto", "Aspas inteligentes", "Troca \" por “ ” ao digitar.", "NSGlobalDomain", "NSAutomaticQuoteSubstitutionEnabled", "bool", True, None, None),
    ("kb.dashes", "Teclado e texto", "Travessões inteligentes", "Troca -- por —.", "NSGlobalDomain", "NSAutomaticDashSubstitutionEnabled", "bool", True, None, None),
    ("kb.caps", "Teclado e texto", "Maiúscula automática", "Inicia frases com maiúscula.", "NSGlobalDomain", "NSAutomaticCapitalizationEnabled", "bool", True, None, None),
    ("ui.dark", "Aparência", "Modo escuro", "Aparência escura em todo o macOS.", "@dark", "", "bool", False, None, None),
    ("ui.scroll", "Aparência", "Rolagem natural", "O conteúdo acompanha o movimento dos dedos.", "NSGlobalDomain", "com.apple.swipescrolldirection", "bool", True, None, None),
    ("ui.savepanel", "Aparência", "Janela Salvar expandida", "Abre o diálogo Salvar já com as pastas visíveis.", "NSGlobalDomain", "NSNavPanelExpandedStateForSaveMode", "bool", False, None, None),
    ("ui.scrollbars", "Aparência", "Barras de rolagem", "Quando mostrar as barras de rolagem.", "NSGlobalDomain", "AppleShowScrollBars", "string", "Automatic", None, [["Automatic", "Automático"], ["WhenScrolling", "Ao rolar"], ["Always", "Sempre"]]),
    ("ui.icloudsave", "Aparência", "Salvar no iCloud por padrão", "Documentos novos vão para o iCloud em vez do Mac.", "NSGlobalDomain", "NSDocumentSaveNewDocumentsToCloud", "bool", True, None, None),
    ("app.textedit", "Apps", "TextEdit em texto simples", "Documentos novos do TextEdit sem formatação.", "com.apple.TextEdit", "RichText", "invbool", True, None, None),
    ("app.tmdisks", "Apps", "Time Machine: não oferecer discos novos", "Para de perguntar se quer usar cada disco novo para backup.", "com.apple.TimeMachine", "DoNotOfferNewDisksForBackup", "bool", False, None, None),
    ("app.photos", "Apps", "Não abrir Fotos ao conectar o iPhone", "Impede o app Fotos de abrir sozinho.", "com.apple.ImageCapture", "disableHotPlug", "bool", False, None, None),
    ("app.crash", "Apps", "Relatório de falha como notificação", "Mostra falhas de apps como notificação em vez de janela.", "com.apple.CrashReporter", "UseUNC", "bool", False, None, None),
]
SET_BY_ID = {s[0]: s for s in SETTINGS}


def defaults_read(domain: str, key: str):
    if domain == "@dark":
        return run(["defaults", "read", "-g", "AppleInterfaceStyle"]).strip() == "Dark"
    rc, out, _ = run_full(["defaults", "read", domain, key], 5)
    return out.strip() if rc == 0 else None


def setting_value(s) -> tuple:
    sid, grp, label, desc, dom, key, typ, default, restart, opts = s
    raw = defaults_read(dom, key)
    if raw is None:
        val, custom = default, False
    elif typ in ("bool", "invbool"):
        b = raw if isinstance(raw, bool) else raw.lower() in ("1", "true", "yes")
        val, custom = (not b if typ == "invbool" else b), True
    elif typ == "int":
        try:
            val = int(float(raw))
        except ValueError:
            val = default
        custom = True
    elif typ == "float":
        try:
            val = float(raw)
        except ValueError:
            val = default
        custom = True
    else:
        val, custom = raw, True
        if sid == "shot.location":
            val = raw.replace(str(HOME), "~")
    return val, custom


def c_config_list(a, ctx):
    out = []
    for s in SETTINGS:
        val, custom = setting_value(s)
        out.append({"id": s[0], "group": s[1], "label": s[2], "desc": s[3], "type": "bool" if s[6] in ("bool", "invbool") else ("choice" if s[9] else s[6]),
                    "value": val, "default": s[7], "custom": custom, "options": s[9], "restart": s[8],
                    "cmd": f"defaults write {s[4]} {s[5]}" if s[4] != "@dark" else "osascript (Aparência)"})
    return {"settings": out}


def apply_setting(s, value) -> None:
    sid, grp, label, desc, dom, key, typ, default, restart, opts = s
    if dom == "@dark":
        r = run_full(["osascript", "-e", f'tell application "System Events" to tell appearance preferences to set dark mode to {"true" if value else "false"}'], 20)
        if r[0] != 0:
            raise Fail("o macOS não deixou mudar a aparência (permita o controle do System Events)")
        return
    if value is None:
        run(["defaults", "delete", dom, key])
    else:
        if opts and value not in [o[0] for o in opts]:
            raise Fail("valor fora das opções")
        if typ in ("bool", "invbool"):
            v = bool(value) if typ == "bool" else not bool(value)
            args = ["-bool", "true" if v else "false"]
        elif typ == "int":
            args = ["-int", str(int(value))]
        elif typ == "float":
            args = ["-float", str(float(value))]
        else:
            sv = str(value)
            if sid == "shot.location":
                full = real(sv)
                if not inside(full, HOME):
                    raise Fail("a pasta precisa estar dentro da sua pasta pessoal")
                full.mkdir(parents=True, exist_ok=True)
                sv = str(full)
            args = ["-string", sv]
        rc, _, err = run_full(["defaults", "write", dom, key] + args, 10)
        if rc != 0:
            raise Fail(err.strip() or "o macOS recusou o ajuste")
    if restart:
        run(RESTART[restart], 10)


def c_config_set(a, ctx):
    s = SET_BY_ID.get(a.get("id"))
    if not s:
        raise Fail("ajuste desconhecido")
    old, custom = setting_value(s)
    value = a.get("value")
    if a.get("reset"):
        value = None
    apply_setting(s, value)
    new, _ = setting_value(s)
    history_add("ajuste", f"{s[2]}: {fmt_val(old)} → {fmt_val(new)}", undo={"type": "config", "id": s[0], "value": old if custom else None},
                origin=a.get("_origin", ""))
    return {"id": s[0], "value": new}


def fmt_val(v) -> str:
    return "sim" if v is True else "não" if v is False else str(v)


# ======================================================================= otimizar
LSREG = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
OPTIMIZE = [
    ("quicklook", "Reconstruir miniaturas do Quick Look", "Limpa e recria o cache de prévias (barra de espaço).", False, [["qlmanage", "-r", "cache"], ["qlmanage", "-r"]]),
    ("finder", "Reiniciar o Finder", "Resolve janelas travadas e ícones errados.", False, [["killall", "Finder"]]),
    ("dock", "Reiniciar o Dock", "Recarrega o Dock, a troca de apps e o Mission Control.", False, [["killall", "Dock"]]),
    ("menubar", "Reiniciar a barra de menus", "Recarrega os ícones da barra de menus.", False, [["killall", "SystemUIServer"], ["killall", "ControlCenter"]]),
    ("launchservices", "Recompor “Abrir com”", "Refaz o registro de apps (duplicados no menu Abrir com).", False,
     [[LSREG, "-kill", "-r", "-domain", "local", "-domain", "system", "-domain", "user"]]),
    ("fonts", "Limpar cache de fontes do usuário", "Resolve fontes com aparência errada.", False, [["atsutil", "databases", "-removeUser"]]),
    ("dns", "Limpar o cache de DNS", "Resolve sites que não abrem depois de mudar de rede ou de DNS.", True, ["dscacheutil -flushcache; killall -HUP mDNSResponder"]),
    ("purge", "Liberar memória inativa", "Esvazia o cache de disco da memória (o macOS refaz conforme o uso).", True, ["/usr/sbin/purge"]),
    ("spotlight", "Reindexar o Spotlight", "Recria o índice de busca do disco de inicialização.", True, ["mdutil -E /"]),
    ("periodic", "Rodar a manutenção do sistema", "Limpa logs e temporários do sistema.", True, ["periodic daily weekly monthly 2>/dev/null || true"]),
]
OPT_BY_ID = {o[0]: o for o in OPTIMIZE}


def c_optimize_list(a, ctx):
    return {"tasks": [{"id": o[0], "label": o[1], "desc": o[2], "admin": o[3]} for o in OPTIMIZE]}


def c_optimize_run(a, ctx):
    o = OPT_BY_ID.get(a.get("id"))
    if not o:
        raise Fail("tarefa desconhecida")
    oid, label, desc, admin, cmds = o
    if admin:  # janela de senha do próprio macOS, no Mac
        script = "; ".join(cmds).replace("\\", "\\\\").replace('"', '\\"')
        rc, out, err = run_full(["osascript", "-e", f'do shell script "{script}" with administrator privileges'], 900)
        if rc != 0:
            raise Fail("cancelado no Mac" if "-128" in err else (err.strip() or "falhou"))
    else:
        for c in cmds:
            rc, out, err = run_full(c, 300)
    history_add("otimizar", label, origin=a.get("_origin", ""))
    return {"id": oid, "label": label}


# ======================================================================= organizar pastas
TEMP_EXT = (".crdownload", ".download", ".part", ".partial", ".opdownload", ".tmp", ".icloud")
MONTHS = ["Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"]


def org_rules() -> list[dict]:
    return load_config().get("rules", [])


def rule_match(r: dict, name: str, st) -> bool:
    if not r.get("enabled", True):
        return False
    ext = Path(name).suffix.lower().lstrip(".")
    exts = [e.lower().lstrip(".") for e in r.get("ext") or [] if e]
    if exts and ext not in exts:
        return False
    if r.get("contains") and r["contains"].lower() not in name.lower():
        return False
    if r.get("older_days") and now() - st.st_mtime < float(r["older_days"]) * 86400:
        return False
    if r.get("larger_mb") and st.st_size < float(r["larger_mb"]) * 1e6:
        return False
    return bool(exts or r.get("contains") or r.get("older_days") or r.get("larger_mb"))


def org_plan(folder: Path, mode: str, use_rules: bool = True, include_dirs: bool = False, min_age_s: float = 120) -> list[dict]:
    if not inside(folder, HOME) and not inside(folder, Path("/Volumes")):
        raise Fail("só pastas dentro da sua pasta pessoal ou de discos externos")
    if folder in (HOME, HOME / "Library") or inside(folder, HOME / "Library"):
        raise Fail("escolha uma pasta como Downloads, Mesa ou Documentos")
    rules = org_rules() if use_rules else []
    kind_names = set(KINDS) | {"Outros", "Pastas"}
    moves = []
    for e in sorted(os.scandir(folder), key=lambda e: e.name.lower()):
        n = e.name
        if n.startswith(".") or n.endswith(TEMP_EXT) or n in ("Icon\r",):
            continue
        try:
            st = e.stat(follow_symlinks=False)
        except OSError:
            continue
        isdir = e.is_dir(follow_symlinks=False)
        pkg = isdir and n.endswith((".app", ".rtfd", ".pages", ".numbers", ".key", ".photoslibrary", ".pkg", ".bundle"))
        if isdir and not pkg:
            if n in kind_names or re.fullmatch(r"\d{4}", n) or not include_dirs or any(r.get("dest") == n for r in rules):
                continue
        if now() - st.st_mtime < min_age_s:
            continue  # ainda pode estar sendo baixado ou editado
        dest = None
        rule = None
        for r in rules:
            if rule_match(r, n, st):
                rule = r
                dest = r.get("dest") or "Outros"
                break
        birth = getattr(st, "st_birthtime", st.st_mtime)
        d = dt.datetime.fromtimestamp(birth)
        if dest is None:
            k = "Pastas" if isdir and not pkg else kind_of(n)
            if mode == "tipo":
                dest = k
            elif mode == "data":
                dest = f"{d.year}/{d.month:02d} - {MONTHS[d.month - 1]}"
            elif mode == "tipo-data":
                dest = f"{k}/{d.year}"
            elif mode == "ano":
                dest = str(d.year)
            else:
                raise Fail("modo desconhecido (tipo, data, tipo-data, ano)")
        if dest == "@lixeira":
            moves.append({"path": e.path, "name": n, "dest": "@lixeira", "rule": rule and rule.get("name"), "size": st.st_size})
            continue
        dpath = Path(os.path.expanduser(dest)) if dest.startswith(("~", "/")) else folder / dest
        dpath = Path(os.path.abspath(dpath))
        if not inside(dpath, HOME) and not inside(dpath, Path("/Volumes")):
            continue
        moves.append({"path": e.path, "name": n, "dest": str(dpath), "rel": dest, "rule": rule and rule.get("name"),
                      "size": st.st_size, "kind": kind_of(n)})
    return moves


def org_apply(moves: list[dict], label: str, origin: str = "", ctx: Ctx | None = None) -> dict:
    done, errors, trash = [], [], []
    for i, m in enumerate(moves):
        if ctx:
            ctx.check(); ctx.progress({"done": i, "total": len(moves), "name": m["name"]})
        src = Path(m["path"])
        if not os.path.lexists(src):
            continue
        if m["dest"] == "@lixeira":
            trash.append(src)
            continue
        try:
            dd = Path(m["dest"])
            dd.mkdir(parents=True, exist_ok=True)
            target = unique(dd / src.name)
            os.rename(src, target)
            done.append([str(src), str(target)])
        except OSError as e:
            errors.append({"path": str(src), "error": e.strerror or str(e)})
    moved_trash = move_to_trash(trash)["moved"] if trash else []
    if done or moved_trash:
        e = history_add("organizar", f"{label}: {len(done)} movidos" + (f", {len(moved_trash)} para a Lixeira" if moved_trash else ""),
                        undo={"type": "organize", "moves": done, "trash": moved_trash}, origin=origin)
        return {"moved": len(done), "trashed": len(moved_trash), "errors": errors, "history": e["id"]}
    return {"moved": 0, "trashed": 0, "errors": errors}


def c_org_preview(a, ctx):
    folder = real(a.get("folder") or str(HOME / "Downloads"))
    if not folder.is_dir():
        raise Fail("pasta não encontrada")
    try:
        moves = org_plan(folder, a.get("mode") or "tipo", a.get("rules", True), bool(a.get("dirs")), 0 if a.get("all") else 120)
    except PermissionError:
        raise Fail("o macOS não liberou essa pasta para o agente — permita o acesso no aviso que apareceu no Mac")
    groups: dict[str, dict] = {}
    for m in moves:
        g = groups.setdefault(m.get("rel") or m["dest"], {"dest": m.get("rel") or m["dest"], "count": 0, "size": 0, "items": []})
        g["count"] += 1; g["size"] += m["size"]
        if len(g["items"]) < 60:
            g["items"].append(m)
    return {"folder": str(folder), "mode": a.get("mode") or "tipo", "moves": len(moves),
            "groups": sorted(groups.values(), key=lambda g: -g["count"]), "plan": new_plan("organize", moves, folder=str(folder))}


def c_org_apply(a, ctx):
    plan = get_plan(a.get("plan"), "organize")
    moves = list(plan["items"].values())
    if a.get("paths"):
        moves = pick(plan, a["paths"])
    PLANS.pop(a.get("plan"), None)
    return org_apply(moves, f"Organizar {Path(plan['folder']).name}", a.get("_origin", ""), ctx)


def c_rules(a, ctx):
    cfg = load_config()
    if "rules" in a:
        rules = []
        for r in a["rules"][:100]:
            if not isinstance(r, dict):
                continue
            dest = str(r.get("dest") or "").strip()
            if not dest:
                continue
            if dest.startswith("/") and not (inside(real(dest), HOME) or inside(real(dest), Path("/Volumes"))):
                raise Fail(f"destino fora da pasta pessoal: {dest}")
            rules.append({"id": r.get("id") or uuid.uuid4().hex[:8], "name": str(r.get("name") or dest)[:80],
                          "ext": [str(e).strip().lstrip(".").lower() for e in (r.get("ext") or []) if str(e).strip()][:40],
                          "contains": str(r.get("contains") or "")[:80], "older_days": float(r.get("older_days") or 0),
                          "larger_mb": float(r.get("larger_mb") or 0), "dest": dest[:300], "enabled": bool(r.get("enabled", True))})
        cfg["rules"] = rules
        save_config(cfg)
    return {"rules": cfg.get("rules", [])}


def c_auto(a, ctx):
    cfg = load_config()
    if "folders" in a:
        out = []
        for f in a["folders"][:20]:
            p = real(f.get("folder", ""))
            if not p.is_dir():
                raise Fail(f"pasta não encontrada: {f.get('folder')}")
            org_plan(p, f.get("mode") or "tipo")  # valida
            out.append({"folder": str(p), "mode": f.get("mode") or "tipo", "enabled": bool(f.get("enabled", True)),
                        "delay_min": max(1, int(f.get("delay_min") or 5))})
        cfg["auto"] = out
        save_config(cfg)
    return {"folders": cfg.get("auto", []), "last": AUTO_STATE}


AUTO_STATE: dict[str, dict] = {}


def auto_loop() -> None:
    while True:
        time.sleep(30)
        for f in load_config().get("auto", []):
            if not f.get("enabled"):
                continue
            try:
                moves = org_plan(Path(f["folder"]), f.get("mode", "tipo"), True, False, f.get("delay_min", 5) * 60)
                if moves:
                    r = org_apply(moves, f"Automático em {Path(f['folder']).name}", "automático")
                    AUTO_STATE[f["folder"]] = {"ts": now(), "moved": r["moved"], "trashed": r.get("trashed", 0)}
                    log("auto", f["folder"], r["moved"])
            except Exception as e:
                AUTO_STATE[f["folder"]] = {"ts": now(), "error": str(e)}


# ======================================================================= histórico e desfazer
def c_history(a, ctx):
    items = history_all()
    items.reverse()
    return {"items": items[: int(a.get("limit") or 200)], "freed_total": sum(e.get("freed", 0) for e in items)}


def c_undo(a, ctx):
    items = history_all()
    e = next((x for x in items if x["id"] == a.get("id")), None)
    if not e or not e.get("undo"):
        raise Fail("essa operação não pode ser desfeita")
    if e.get("undone"):
        raise Fail("já foi desfeita")
    u = e["undo"]
    ok = fail = 0
    if u["type"] in ("trash", "organize"):
        pairs = [(m[1], m[0]) for m in u.get("moves", [])] + [(m[1], m[0]) for m in u.get("trash", [])]
        for cur, orig in pairs:
            if not cur or not os.path.lexists(cur) or os.path.lexists(orig):
                fail += 1
                continue
            try:
                Path(orig).parent.mkdir(parents=True, exist_ok=True)
                os.rename(cur, orig)
                ok += 1
            except OSError:
                fail += 1
        if u["type"] == "organize":  # pastas criadas e agora vazias
            for cur, orig in pairs:
                try:
                    os.rmdir(os.path.dirname(cur))
                except OSError:
                    pass
    elif u["type"] == "config":
        apply_setting(SET_BY_ID[u["id"]], u["value"]); ok = 1
    elif u["type"] == "startup":
        c_startup_toggle({"label": u["label"], "on": u["on"]}, ctx); ok = 1
    e["undone"] = now()
    history_rewrite(items)
    return {"restored": ok, "failed": fail}


# ======================================================================= shell (só se ligado no Mac)
def c_shell(a, ctx):
    if not load_config().get("allow_shell"):
        raise Fail("comandos livres estão desligados. No Terminal do Mac: station shell ligar")
    cmd = str(a.get("cmd") or "")
    if not cmd.strip():
        raise Fail("comando vazio")
    p = subprocess.Popen(["/bin/zsh", "-lc", cmd], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=str(HOME),
                         stdin=subprocess.DEVNULL, start_new_session=True)
    out_q: queue.Queue = queue.Queue()
    threading.Thread(target=lambda: [out_q.put(l) for l in iter(p.stdout.readline, b"")] or out_q.put(None), daemon=True).start()
    buf, total, t0 = [], 0, now()
    while True:
        try:
            line = out_q.get(timeout=0.2)
        except queue.Empty:
            line = ""
        if ctx.cancel.is_set() or now() - t0 > 900:
            os.killpg(p.pid, signal.SIGTERM)
            break
        if line is None:
            break
        if line:
            s = line.decode(errors="replace")
            total += len(s)
            if total < 2_000_000:
                buf.append(s)
        if buf and (len(buf) > 30 or not line):
            ctx.progress({"out": "".join(buf)}, force=True)
            buf = []
    rc = p.wait()
    if buf:
        ctx.progress({"out": "".join(buf)}, force=True)
    history_add("shell", cmd[:200], origin=a.get("_origin", ""))
    return {"code": rc}


COMMANDS = {
    "info": c_info, "snapshot": c_snapshot, "procs": c_procs, "proc.kill": c_kill, "energy": c_energy, "net": c_net, "battery": c_battery,
    "storage": c_storage, "volumes": c_volumes, "analyze": c_analyze, "large": c_large, "trash": c_trash, "reveal": c_reveal,
    "settings.open": c_open_settings, "clean.scan": c_clean_scan, "clean.apply": c_clean_apply, "trash.empty": c_trash_empty,
    "whitelist": c_whitelist, "purge.scan": c_purge_scan, "purge.apply": c_purge_apply, "installers": c_installers,
    "plan.trash": c_plan_trash, "dupes": c_dupes, "apps": c_apps, "apps.leftovers": c_leftovers, "apps.uninstall": c_uninstall,
    "startup": c_startup, "startup.toggle": c_startup_toggle, "config.list": c_config_list, "config.set": c_config_set,
    "optimize.list": c_optimize_list, "optimize.run": c_optimize_run, "organize.preview": c_org_preview, "organize.apply": c_org_apply,
    "organize.rules": c_rules, "organize.auto": c_auto, "history": c_history, "undo": c_undo, "shell": c_shell,
}


def execute(cmd: str, args: dict, ctx: Ctx) -> dict:
    fn = COMMANDS.get(cmd)
    if not fn:
        raise Fail(f"comando desconhecido: {cmd}")
    if not isinstance(args, dict):
        args = {}
    return fn(args, ctx)


# ======================================================================= WebSocket (cliente mínimo, RFC 6455)
class WS:
    def __init__(self, url: str, headers: dict):
        u = urllib.parse.urlparse(url)
        port = u.port or (443 if u.scheme == "wss" else 80)
        raw = socket.create_connection((u.hostname, port), timeout=20)
        if u.scheme == "wss":
            ctxs = ssl.create_default_context()
            if os.path.exists("/etc/ssl/cert.pem"):
                try:
                    ctxs.load_verify_locations("/etc/ssl/cert.pem")
                except (OSError, ssl.SSLError):
                    pass
            raw = ctxs.wrap_socket(raw, server_hostname=u.hostname)
        self.sock = raw
        key = base64.b64encode(os.urandom(16)).decode()
        path = (u.path or "/") + (("?" + u.query) if u.query else "")
        host = u.hostname + (f":{u.port}" if u.port else "")
        req = [f"GET {path} HTTP/1.1", f"Host: {host}", "Upgrade: websocket", "Connection: Upgrade",
               f"Sec-WebSocket-Key: {key}", "Sec-WebSocket-Version: 13", f"User-Agent: GPlanSiS-Station/{VERSION}"]
        req += [f"{k}: {v}" for k, v in headers.items()]
        self.sock.sendall(("\r\n".join(req) + "\r\n\r\n").encode())
        resp = b""
        while b"\r\n\r\n" not in resp:
            c = self.sock.recv(4096)
            if not c:
                raise ConnectionError("conexão fechada no aperto de mão")
            resp += c
            if len(resp) > 65536:
                raise ConnectionError("resposta grande demais")
        head, _, self.buf = resp.partition(b"\r\n\r\n")
        lines = head.decode(errors="replace").split("\r\n")
        if " 101 " not in lines[0] + " ":
            code = lines[0].split(" ")[1] if len(lines[0].split(" ")) > 1 else "?"
            raise PermissionError(code) if code in ("401", "403") else ConnectionError(lines[0])
        accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
        if not any(l.lower().startswith("sec-websocket-accept:") and l.split(":", 1)[1].strip() == accept for l in lines):
            raise ConnectionError("aperto de mão inválido")
        self.sock.settimeout(None)
        self.wlock = threading.Lock()
        self.closed = False

    def _read(self, n: int) -> bytes:
        while len(self.buf) < n:
            c = self.sock.recv(65536)
            if not c:
                raise ConnectionError("conexão fechada")
            self.buf += c
        out, self.buf = self.buf[:n], self.buf[n:]
        return out

    def _frame(self, op: int, data: bytes) -> None:
        h = bytearray([0x80 | op])
        n = len(data)
        if n < 126:
            h.append(0x80 | n)
        elif n < 65536:
            h.append(0x80 | 126); h += struct.pack(">H", n)
        else:
            h.append(0x80 | 127); h += struct.pack(">Q", n)
        mask = os.urandom(4)
        h += mask
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) if n < 4096 else _xor(data, mask)
        with self.wlock:
            self.sock.sendall(bytes(h) + masked)

    def send(self, obj: dict) -> None:
        if self.closed:
            raise ConnectionError("fechada")
        self._frame(1, json.dumps(obj, ensure_ascii=False, separators=(",", ":"), default=str).encode())

    def recv(self) -> dict | None:
        parts, opcode = [], None
        while True:
            b1, b2 = self._read(2)
            op, fin = b1 & 0x0F, b1 & 0x80
            n = b2 & 0x7F
            if n == 126:
                n = struct.unpack(">H", self._read(2))[0]
            elif n == 127:
                n = struct.unpack(">Q", self._read(8))[0]
            if n > 32 * 1024 * 1024:
                raise ConnectionError("mensagem grande demais")
            mask = self._read(4) if b2 & 0x80 else None
            data = self._read(n)
            if mask:
                data = _xor(data, mask)
            if op == 9:
                self._frame(10, data); continue
            if op == 10:
                continue
            if op == 8:
                self.closed = True
                return None
            if op in (1, 2):
                opcode = op; parts = [data]
            elif op == 0:
                parts.append(data)
            if fin:
                try:
                    return json.loads(b"".join(parts).decode())
                except ValueError:
                    return {}

    def close(self) -> None:
        self.closed = True
        try:
            self._frame(8, struct.pack(">H", 1000))
        except OSError:
            pass
        try:
            self.sock.close()
        except OSError:
            pass


def _xor(data: bytes, mask: bytes) -> bytes:
    m = int.from_bytes((mask * (len(data) // 4 + 1))[: len(data)], "big")
    return (int.from_bytes(data, "big") ^ m).to_bytes(len(data), "big") if data else b""


# ======================================================================= modo agente (conectado ao painel)
class Agent:
    def __init__(self, cfg: dict):
        self.cfg = cfg
        self.ws: WS | None = None
        self.watch = {"on": False, "procs": False}
        self.jobs: dict[str, Ctx] = {}
        self.pool = cf.ThreadPoolExecutor(4)
        self.stop = threading.Event()

    def send(self, obj: dict) -> None:
        ws = self.ws
        if ws:
            try:
                ws.send(obj)
            except OSError:
                pass

    def metrics_loop(self) -> None:
        s = sampler()
        last_min = now()
        while not self.stop.is_set():
            t0 = now()
            try:
                if self.watch["on"]:
                    self.send({"t": "m", "d": s.sample(procs=self.watch["procs"])})
                else:
                    s.sample(False)
                    time.sleep(4)
                if now() - last_min >= 60:
                    last_min = now()
                    h = s.minute_summary()
                    if h:
                        self.send({"t": "h", "d": h})
            except Exception as e:
                log("métricas:", repr(e))
            time.sleep(max(0.1, 1.0 - (now() - t0)))

    def handle(self, msg: dict) -> None:
        t = msg.get("t")
        if t == "watch":
            self.watch = {"on": bool(msg.get("on")), "procs": bool(msg.get("procs"))}
        elif t == "req":
            rid = str(msg.get("id"))
            ctx = Ctx(lambda d, rid=rid: self.send({"t": "prog", "id": rid, "d": d}))
            self.jobs[rid] = ctx
            self.pool.submit(self.job, rid, msg.get("cmd", ""), msg.get("args") or {}, ctx, msg.get("who", ""))
        elif t == "cancel":
            c = self.jobs.get(str(msg.get("id")))
            if c:
                c.cancel.set()

    def job(self, rid: str, cmd: str, args: dict, ctx: Ctx, who: str) -> None:
        try:
            args["_origin"] = f"painel ({who})" if who else "painel"
            data = execute(cmd, args, ctx)
            self.send({"t": "res", "id": rid, "ok": True, "data": data})
        except Cancelled:
            self.send({"t": "res", "id": rid, "ok": False, "error": "cancelado"})
        except Fail as e:
            self.send({"t": "res", "id": rid, "ok": False, "error": str(e)})
        except Exception as e:
            log("erro em", cmd, repr(e))
            self.send({"t": "res", "id": rid, "ok": False, "error": f"erro interno: {e}"})
        finally:
            self.jobs.pop(rid, None)

    def serve(self) -> None:
        threading.Thread(target=self.metrics_loop, daemon=True).start()
        threading.Thread(target=auto_loop, daemon=True).start()
        server = self.cfg.get("server", DEFAULT_SERVER).rstrip("/")
        url = server.replace("https://", "wss://").replace("http://", "ws://") + "/ws/agent"
        delay = 2
        while not self.stop.is_set():
            try:
                self.ws = WS(url, {"Authorization": "Bearer " + self.cfg["token"]})
                log("conectado a", server)
                delay = 2
                self.send({"t": "hello", "v": VERSION, "info": system_info()})
                while True:
                    m = self.ws.recv()
                    if m is None:
                        break
                    self.handle(m)
            except PermissionError:
                log("o painel recusou a chave deste Mac — conecte de novo com um código novo")
                delay = 300
            except Exception as e:
                log("conexão:", repr(e))
            finally:
                if self.ws:
                    self.ws.close()
                self.ws = None
                self.watch = {"on": False, "procs": False}
                for c in self.jobs.values():
                    c.cancel.set()
            time.sleep(delay)
            delay = min(delay * 2, 60)


# ======================================================================= instalação e pareamento
def pair(code: str, server: str) -> None:
    info = system_info()
    body = json.dumps({"code": code.strip().upper(), "name": info["computer"], "info": info}).encode()
    req = urllib.request.Request(server.rstrip("/") + "/api/agent/pair", data=body, method="POST",
                                 headers={"Content-Type": "application/json", "X-Station": "1", "User-Agent": f"GPlanSiS-Station/{VERSION}"})
    ctxs = ssl.create_default_context()
    if os.path.exists("/etc/ssl/cert.pem"):
        try:
            ctxs.load_verify_locations("/etc/ssl/cert.pem")
        except (OSError, ssl.SSLError):
            pass
    try:
        with urllib.request.urlopen(req, timeout=20, context=ctxs if server.startswith("https") else None) as r:
            d = json.loads(r.read())
    except urllib.error.HTTPError as e:
        try:
            msg = json.loads(e.read()).get("detail")
        except Exception:
            msg = str(e)
        raise SystemExit(f"✗ {msg}")
    cfg = load_config()
    cfg.update(server=server.rstrip("/"), token=d["token"], mac_id=d["mac_id"], paired_at=now(), owner=d.get("owner"))
    save_config(cfg)
    print(f"✓ Este Mac ({info['computer']}) agora aparece no painel de {d.get('owner', 'sua conta')}.")


def install_service() -> None:
    PLIST.parent.mkdir(parents=True, exist_ok=True)
    script = str(Path(__file__).resolve())
    pl = {"Label": LABEL, "ProgramArguments": [sys.executable, script, "agente"], "RunAtLoad": True, "KeepAlive": True,
          "ProcessType": "Background", "LowPriorityIO": True, "Nice": 5,
          "StandardOutPath": str(SUPPORT / "saida.log"), "StandardErrorPath": str(SUPPORT / "saida.log"),
          "EnvironmentVariables": {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"}}
    dom = f"gui/{os.getuid()}"
    run(["launchctl", "bootout", dom, str(PLIST)], 10)
    with open(PLIST, "wb") as f:
        plistlib.dump(pl, f)
    rc, _, err = run_full(["launchctl", "bootstrap", dom, str(PLIST)], 15)
    if rc != 0 and "already" not in err.lower():
        run(["launchctl", "load", "-w", str(PLIST)], 15)
    print("✓ Agente instalado: inicia sozinho quando você entra no Mac.")


def uninstall_service(forget: bool) -> None:
    run(["launchctl", "bootout", f"gui/{os.getuid()}", str(PLIST)], 10)
    try:
        PLIST.unlink()
    except OSError:
        pass
    if forget:
        cfg = load_config()
        for k in ("token", "mac_id", "owner"):
            cfg.pop(k, None)
        save_config(cfg)
    print("✓ Agente parado e removido do início automático." + (" Este Mac foi desconectado do painel." if forget else ""))


# ======================================================================= Terminal (CLI local)
B, DIM, RST, GRN, YEL, RED, BLU = "\033[1m", "\033[2m", "\033[0m", "\033[32m", "\033[33m", "\033[31m", "\033[34m"
if not sys.stdout.isatty():
    B = DIM = RST = GRN = YEL = RED = BLU = ""


def bar(pct: float, w: int = 24) -> str:
    pct = max(0.0, min(100.0, pct or 0))
    n = int(round(pct * w / 100))
    col = GRN if pct < 60 else YEL if pct < 85 else RED
    return col + "█" * n + DIM + "░" * (w - n) + RST


def table(rows: list[list], heads: list[str], right: set[int] = frozenset()) -> None:
    rows = [[str(c) for c in r] for r in rows]
    ws = [max([len(h)] + [len(r[i]) for r in rows]) for i, h in enumerate(heads)]
    print(B + "  ".join(h.rjust(ws[i]) if i in right else h.ljust(ws[i]) for i, h in enumerate(heads)) + RST)
    for r in rows:
        print("  ".join(c.rjust(ws[i]) if i in right else c.ljust(ws[i]) for i, c in enumerate(r)))


def ask(q: str) -> bool:
    try:
        return input(f"{q} [s/N] ").strip().lower() in ("s", "sim", "y", "yes")
    except EOFError:
        return False


def cli_progress(d):
    c = d.get("cat")
    s = d.get("path") or d.get("name") or (c.get("label") if isinstance(c, dict) else c) or ""
    if sys.stderr.isatty():
        sys.stderr.write("\r\033[K" + DIM + str(s)[-90:] + RST)
        sys.stderr.flush()


def cli_clear():
    if sys.stderr.isatty():
        sys.stderr.write("\r\033[K")


HELP = f"""{B}GPlanSiS|Station {VERSION}{RST} — limpeza, armazenamento e ajustes do Mac

{B}Monitorar{RST}
  status                 saúde, CPU, memória, disco, rede, bateria
  top [n]                processos que mais usam CPU
  rede                   interfaces, DNS, portas abertas, uso por app
  bateria                saúde, ciclos, temperatura, consumo
{B}Armazenamento{RST}
  disco                  categorias de uso e volumes
  analisar [pasta]       tamanho de cada item da pasta
  grandes [pasta] [MB]   arquivos grandes (padrão: 200 MB na pasta pessoal)
{B}Limpeza{RST}
  limpar [--simular]     caches, logs, temporários, Lixeira…
  purgar [pasta]         node_modules, build, target… de projetos
  instaladores           .dmg/.pkg esquecidos
  duplicados [pasta]     arquivos repetidos (vão para a Lixeira)
  apps                   aplicativos por tamanho e último uso
  desinstalar <App>      remove o app e as sobras dele
{B}Organizar{RST}
  organizar [pasta] [--modo tipo|data|tipo-data|ano] [--simular]
  regras                 regras de organização salvas
{B}Mac{RST}
  ajustes                ajustes do macOS (Finder, Dock, capturas…)
  ajustar <id> <valor>   muda um ajuste (ex.: ajustar finder.hidden sim)
  otimizar [tarefa]      rotinas de manutenção
  inicio                 itens que iniciam com o Mac
  historico              o que o Station já fez (com desfazer)
  desfazer <id>          desfaz uma operação do histórico
{B}Agente{RST}
  conectar <código>      liga este Mac ao painel (station.gplansis.com)
  instalar | remover     inicia sozinho com o Mac / tira do início
  shell ligar|desligar   permite comandos livres vindos do painel
  sobre                  versão, conexão e permissões
"""


def cli(argv: list[str]) -> int:
    if not argv or argv[0] in ("ajuda", "help", "-h", "--help"):
        print(HELP)
        return 0
    cmd, rest = argv[0], argv[1:]
    flags = {a for a in rest if a.startswith("--")}
    pos = [a for a in rest if not a.startswith("--")]
    ctx = Ctx(cli_progress)

    def opt(name, default=None):
        for i, a in enumerate(rest):
            if a == name and i + 1 < len(rest):
                return rest[i + 1]
            if a.startswith(name + "="):
                return a.split("=", 1)[1]
        return default

    if cmd == "agente":
        cfg = load_config()
        if not cfg.get("token"):
            print("Este Mac ainda não foi conectado: station conectar <código>")
            return 1
        signal.signal(signal.SIGTERM, lambda *a: os._exit(0))
        Agent(cfg).serve()
        return 0
    if cmd == "conectar":
        if not pos:
            print("Uso: station conectar <código>  (o código aparece em station.gplansis.com › Adicionar Mac)")
            return 1
        pair(pos[0], opt("--servidor", load_config().get("server", DEFAULT_SERVER)))
        if "--sem-servico" not in flags:
            install_service()
        return 0
    if cmd == "instalar":
        install_service(); return 0
    if cmd == "remover":
        uninstall_service("--desconectar" in flags); return 0
    if cmd == "shell":
        cfg = load_config()
        if pos and pos[0] in ("ligar", "desligar"):
            cfg["allow_shell"] = pos[0] == "ligar"
            save_config(cfg)
        print("Comandos livres do painel: " + (f"{YEL}LIGADOS{RST} (o painel pode rodar qualquer comando como você)" if cfg.get("allow_shell") else f"{GRN}desligados{RST}"))
        return 0
    if cmd == "sobre":
        cfg, i = load_config(), system_info()
        print(f"{B}GPlanSiS|Station {VERSION}{RST}\n  Mac: {i['computer']} · {i['model']} · {i['chip']} · macOS {i['macos']}")
        print(f"  Painel: {cfg.get('server', '—')} · {'conectado a ' + str(cfg.get('owner')) if cfg.get('token') else 'não conectado'}")
        print(f"  Serviço: {'instalado' if PLIST.exists() else 'não instalado'} · Python {i['python']} ({i['python_path']})")
        print(f"  Acesso Total ao Disco: {'sim' if i['fda'] else 'não (Lixeira, Mail e Safari ficam fora da análise)'}")
        print(f"  Comandos livres: {'ligados' if i['shell'] else 'desligados'}")
        return 0
    try:
        return cli_cmd(cmd, pos, flags, opt, ctx)
    except Fail as e:
        cli_clear()
        print(f"{RED}✗ {e}{RST}")
        return 1
    except KeyboardInterrupt:
        cli_clear()
        print("cancelado")
        return 130


def cli_cmd(cmd, pos, flags, opt, ctx) -> int:
    if cmd == "status":
        s = sampler()
        time.sleep(0.7)
        d = s.sample(True)
        i = system_info()
        m, dk, b = d["mem"], d["disk"], d.get("battery")
        hs = d["health"]
        col = GRN if hs["score"] >= 80 else YEL if hs["score"] >= 60 else RED
        print(f"{B}{i['computer']}{RST} · {i['model']} · {i['chip']} · macOS {i['macos']} · ligado há {fmt_dur(d['uptime'])}")
        print(f"Saúde  {col}{B}{hs['score']}{RST}/100 " + (DIM + "· " + ", ".join(hs["why"]) + RST if hs["why"] else ""))
        print(f"CPU    {bar(d['cpu']['total'])} {d['cpu']['total']:5.1f}%   carga {' '.join(str(x) for x in d['load'])}")
        mp = m.get("used", 0) * 100 / max(m["total"], 1)
        print(f"Memória{bar(mp)} {human(m.get('used'))} de {human(m['total'])} · pressão {m.get('pressure')} · swap {human(m.get('swap_used'))}")
        dp = dk["used"] * 100 / max(dk["total"], 1)
        print(f"Disco  {bar(dp)} {human(dk['free'])} livres de {human(dk['total'])}")
        if d["gpu"] is not None:
            print(f"GPU    {bar(d['gpu'])} {d['gpu']}%")
        print(f"Rede   ↓ {human(d['net']['rx'])}/s  ↑ {human(d['net']['tx'])}/s")
        if b:
            print(f"Bateria{bar(b['percent'] or 0)} {b['percent']}% · {'carregando' if b['charging'] else 'na tomada' if b['external'] else 'na bateria'}"
                  + (f" · saúde {b['health']}%" if b.get("health") else "") + (f" · {b['cycles']} ciclos" if b.get("cycles") else ""))
        print(f"{DIM}{d['procs']['count']} processos · mais ocupados: " + ", ".join(p["name"] for p in d["procs"]["list"][:4]) + RST)
        return 0
    if cmd == "top":
        r = c_procs({"limit": int(pos[0]) if pos else 15}, ctx)
        table([[p["pid"], p["name"][:32], f"{p['cpu']:.1f}", human(p["mem"]), p["user"]] for p in r["list"]], ["PID", "Processo", "%CPU", "Memória", "Usuário"], {0, 2, 3})
        return 0
    if cmd == "rede":
        r = c_net({}, ctx)
        for i in r["interfaces"]:
            if i["ipv4"]:
                print(f"{B}{i['port'] or i['name']}{RST} ({i['name']}) {', '.join(i['ipv4'])}")
        print(f"Roteador {r['gateway']} · DNS {', '.join(r['dns'])}" + (" · proxy ligado" if r["proxy"] else ""))
        if r["procs"]:
            print(B + "\nUso por app (último segundo)" + RST)
            table([[p["name"][:30], human(p["rx"]) + "/s", human(p["tx"]) + "/s"] for p in r["procs"][:12]], ["App", "↓", "↑"], {1, 2})
        if r["listening"]:
            print(B + "\nPortas abertas" + RST)
            table([[p["port"], p["name"], p["addr"]] for p in r["listening"][:15]], ["Porta", "Processo", "Endereço"], {0})
        return 0
    if cmd == "bateria":
        b = battery()
        if not b:
            print("Este Mac não tem bateria."); return 0
        for k, v in (("Carga", f"{b['percent']}%"), ("Estado", "carregando" if b["charging"] else "na tomada" if b["external"] else "na bateria"),
                     ("Saúde", f"{b['health']}%" if b.get("health") else "—"), ("Ciclos", b.get("cycles")), ("Condição", b.get("condition")),
                     ("Temperatura", f"{b['temp_c']} °C" if b.get("temp_c") else "—"), ("Consumo", f"{abs(b['watts'])} W " + ("da tomada" if b["watts"] > 0 else "da bateria")),
                     ("Restante", fmt_dur((b.get("minutes_left") or 0) * 60) if b.get("minutes_left") else "—")):
            print(f"{k:12} {v}")
        return 0
    if cmd == "disco":
        r = c_storage({}, ctx); cli_clear()
        v = r["volume"]
        print(f"{B}Macintosh HD{RST} {bar(v['used'] * 100 / v['total'])} {human(v['free'])} livres de {human(v['total'])}\n")
        table([[c["label"], human(c["size"]) + (" (sem permissão)" if c["denied"] else "")] for c in sorted(r["cats"], key=lambda c: -c["size"])], ["Categoria", "Tamanho"], {1})
        others = [x for x in r["volumes"] if not x["internal"]]
        if others:
            print()
            table([[x["name"], human(x["free"]), human(x["total"])] for x in others], ["Disco externo", "Livre", "Total"], {1, 2})
        return 0
    if cmd == "analisar":
        r = c_analyze({"path": pos[0] if pos else str(HOME)}, ctx); cli_clear()
        print(f"{B}{r['path']}{RST} · {human(r['total'])}")
        tot = max(r["total"], 1)
        for it in r["items"][:int(opt("--n", 30))]:
            print(f"{human(it['size']):>9}  {bar(it['size'] * 100 / tot, 16)}  {it['name']}{'/' if it['dir'] else ''}")
        return 0
    if cmd == "grandes":
        root = pos[0] if pos and not pos[0].isdigit() else str(HOME)
        mb = next((p for p in pos if p.replace(".", "").isdigit()), "200")
        r = c_large({"path": root, "min_mb": mb}, ctx); cli_clear()
        table([[human(it["size"]), it["path"].replace(str(HOME), "~")] for it in r["items"][:50]], ["Tamanho", "Arquivo"], {0})
        print(f"{DIM}{len(r['items'])} arquivos com {mb} MB ou mais{RST}")
        return 0
    if cmd == "limpar":
        r = c_clean_scan({}, ctx); cli_clear()
        chosen = []
        for c in r["cats"]:
            if not c["count"] and not c["denied"]:
                continue
            mark = f"{GRN}●{RST}" if c["default"] else f"{DIM}○{RST}"
            print(f" {mark} {c['label']:34} {human(c['size']):>10}" + (f"  {YEL}sem permissão{RST}" if c["denied"] else ""))
            if c["default"] or c["id"] in flags_cats(flags):
                chosen += [i["path"] for i in PLANS[r["plan"]]["items"].values() if i["cat"] == c["id"]]
        total = sum(PLANS[r["plan"]]["items"][p]["size"] for p in chosen)
        print(f"\n{B}Total marcado: {human(total)}{RST}  {DIM}(○ = só com --dev, --backups, --janelas){RST}")
        if not r["fda"]:
            print(f"{YEL}Sem Acesso Total ao Disco: a Lixeira e o Mail ficam de fora.{RST}")
        if "--simular" in flags or not chosen:
            return 0
        if not ask("Apagar de vez esses itens?"):
            return 0
        res = c_clean_apply({"plan": r["plan"], "paths": chosen, "_origin": "Terminal"}, ctx); cli_clear()
        print(f"{GRN}✓ {human(res['freed'])} liberados{RST}" + (f" · {res['error_count']} itens protegidos pelo macOS" if res["error_count"] else ""))
        return 0
    if cmd == "purgar":
        r = c_purge_scan({"roots": pos}, ctx); cli_clear()
        table([[human(i["size"]), ("" if not i["recent"] else "recente ") , i["path"].replace(str(HOME), "~")] for i in r["items"][:60]], ["Tamanho", "", "Pasta"], {0})
        chosen = [i["path"] for i in r["items"] if not i["recent"]]
        print(f"\n{B}{human(sum(i['size'] for i in r['items'] if not i['recent']))}{RST} em {len(chosen)} pastas (projetos mexidos nos últimos 7 dias ficam)")
        if "--simular" in flags or not chosen or not ask("Apagar essas pastas? (se recriam com npm install, cargo build…)"):
            return 0
        res = c_purge_apply({"plan": r["plan"], "paths": chosen, "_origin": "Terminal"}, ctx); cli_clear()
        print(f"{GRN}✓ {human(res['freed'])} liberados{RST}")
        return 0
    if cmd == "instaladores":
        r = c_installers({}, ctx)
        table([[human(i["size"]), i["path"].replace(str(HOME), "~")] for i in r["items"]], ["Tamanho", "Instalador"], {0})
        if r["items"] and "--simular" not in flags and ask(f"Mover os {len(r['items'])} instaladores ({human(r['total'])}) para a Lixeira?"):
            res = c_plan_trash({"plan": r["plan"], "paths": [i["path"] for i in r["items"]], "_origin": "Terminal"}, ctx)
            print(f"{GRN}✓ {len(res['moved'])} na Lixeira{RST} · desfazer: station desfazer {res.get('history')}")
        return 0
    if cmd == "duplicados":
        r = c_dupes({"path": pos[0] if pos else str(HOME / "Downloads")}, ctx); cli_clear()
        for g in r["groups"][:30]:
            print(f"{B}{human(g['size'])}{RST} × {len(g['files'])}  {g['name']}")
            for f in g["files"]:
                print(f"   {DIM}{f['path'].replace(str(HOME), '~')}{RST}")
        print(f"\n{len(r['groups'])} grupos · {human(r['waste'])} repetidos em {r['files']} arquivos")
        extra = [f["path"] for g in r["groups"] for f in g["files"][1:]]
        if extra and "--simular" not in flags and ask("Mover as cópias (fica o arquivo mais antigo de cada grupo) para a Lixeira?"):
            res = c_plan_trash({"plan": r["plan"], "kind": "dupes", "paths": extra, "_origin": "Terminal"}, ctx)
            print(f"{GRN}✓ {len(res['moved'])} cópias na Lixeira{RST} · desfazer: station desfazer {res.get('history')}")
        return 0
    if cmd == "apps":
        r = c_apps({}, ctx); cli_clear()
        table([[human(a_["size"]), a_["name"][:34], a_["version"][:12], fmt_date(a_.get("last_used"))] for a_ in r["apps"][:int(opt("--n", 40))]],
              ["Tamanho", "App", "Versão", "Último uso"], {0})
        return 0
    if cmd == "desinstalar":
        if not pos:
            raise Fail("diga o app: station desinstalar \"Nome do App\"")
        name = " ".join(pos).lower().removesuffix(".app")
        cand = [p for p in app_dirs() if p.stem.lower() == name or app_info(p)["name"].lower() == name]
        if not cand:
            raise Fail("app não encontrado em /Applications")
        r = c_leftovers({"path": str(cand[0])}, ctx)
        table([[("●" if i["default"] else "○"), human(i["size"]), i["kind"], i["name"]] for i in r["items"]], ["", "Tamanho", "Tipo", "Item"], {1})
        chosen = [i["path"] for i in r["items"] if i["default"] or "--com-dados" in flags]
        if r["running"]:
            raise Fail(f"feche o {r['app']['name']} primeiro")
        if ask(f"Mover {len(chosen)} itens para a Lixeira?" + ("" if "--com-dados" in flags else " (○ dados pessoais ficam; use --com-dados)")):
            res = c_uninstall({"plan": r["plan"], "paths": chosen, "_origin": "Terminal"}, ctx)
            print(f"{GRN}✓ {r['app']['name']} desinstalado ({human(res['freed'])}){RST} · desfazer: station desfazer {res.get('history')}")
        return 0
    if cmd == "organizar":
        folder = pos[0] if pos else str(HOME / "Downloads")
        r = c_org_preview({"folder": folder, "mode": opt("--modo", "tipo"), "all": True}, ctx)
        for g in r["groups"]:
            print(f"{B}{g['dest']}{RST}  {g['count']} itens · {human(g['size'])}")
        print(f"\n{r['moves']} itens em {r['folder'].replace(str(HOME), '~')}")
        if r["moves"] and "--simular" not in flags and ask("Organizar agora?"):
            res = c_org_apply({"plan": r["plan"], "_origin": "Terminal"}, ctx)
            print(f"{GRN}✓ {res['moved']} itens organizados{RST} · desfazer: station desfazer {res.get('history')}")
        return 0
    if cmd == "regras":
        rs = org_rules()
        if not rs:
            print("Nenhuma regra. Crie no painel (Organizar › Regras).")
        for r in rs:
            cond = ", ".join(x for x in ((" ".join("." + e for e in r["ext"])) if r["ext"] else "", f"contém “{r['contains']}”" if r["contains"] else "",
                                         f"> {r['older_days']:g} dias" if r["older_days"] else "", f"> {r['larger_mb']:g} MB" if r["larger_mb"] else "") if x)
            print(f"{'●' if r['enabled'] else '○'} {B}{r['name']}{RST}: {cond} → {r['dest']}")
        return 0
    if cmd == "ajustes":
        cur = None
        for s in c_config_list({}, ctx)["settings"]:
            if s["group"] != cur:
                cur = s["group"]; print(f"\n{B}{cur}{RST}")
            print(f"  {s['id']:18} {fmt_val(s['value']):>12}  {DIM}{s['label']}{RST}")
        return 0
    if cmd == "ajustar":
        if len(pos) < 2:
            raise Fail("uso: station ajustar <id> <valor|padrão>")
        s = SET_BY_ID.get(pos[0])
        if not s:
            raise Fail("ajuste desconhecido (veja station ajustes)")
        raw = " ".join(pos[1:])
        if raw in ("padrão", "padrao", "reset"):
            r = c_config_set({"id": pos[0], "reset": True, "_origin": "Terminal"}, ctx)
        else:
            typ = s[6]
            val = (raw.lower() in ("sim", "s", "1", "true", "ligado", "on")) if typ in ("bool", "invbool") else int(raw) if typ == "int" else float(raw.replace(",", ".")) if typ == "float" else raw
            r = c_config_set({"id": pos[0], "value": val, "_origin": "Terminal"}, ctx)
        print(f"{GRN}✓ {s[2]}: {fmt_val(r['value'])}{RST}")
        return 0
    if cmd == "otimizar":
        if not pos:
            for o in OPTIMIZE:
                print(f"  {o[0]:15} {o[1]}" + (f" {DIM}(pede senha){RST}" if o[3] else ""))
            print(f"\n{DIM}station otimizar <tarefa>{RST}")
            return 0
        r = c_optimize_run({"id": pos[0], "_origin": "Terminal"}, ctx)
        print(f"{GRN}✓ {r['label']}{RST}")
        return 0
    if cmd == "inicio":
        r = c_startup({}, ctx)
        table([[("●" if i["loaded"] else "○"), i["scope"], i["label"][:46], ("desligado" if i["disabled"] else "")] for i in r["items"]], ["", "Escopo", "Item", ""])
        return 0
    if cmd == "historico":
        for e in c_history({"limit": 40}, ctx)["items"]:
            print(f"{DIM}{fmt_date(e['ts'], True)}{RST} {e['id']}  {e['summary']}" + (f" · {human(e['freed'])}" if e.get("freed") else "")
                  + (f" {DIM}(desfeito){RST}" if e.get("undone") else (f" {BLU}↺{RST}" if e.get("undo") else "")))
        return 0
    if cmd == "desfazer":
        if not pos:
            raise Fail("uso: station desfazer <id> (veja station historico)")
        r = c_undo({"id": pos[0]}, ctx)
        print(f"{GRN}✓ {r['restored']} restaurados{RST}" + (f" · {r['failed']} não puderam voltar" if r["failed"] else ""))
        return 0
    raise Fail(f"comando desconhecido: {cmd} (station ajuda)")


def flags_cats(flags) -> set[str]:
    m = {"--dev": "dev", "--backups": "ios_backups", "--janelas": "saved_state"}
    return {v for k, v in m.items() if k in flags}


def fmt_dur(s) -> str:
    if not s:
        return "—"
    s = int(s)
    d, h, m = s // 86400, s % 86400 // 3600, s % 3600 // 60
    return f"{d}d {h}h" if d else f"{h}h {m}min" if h else f"{m}min"


def fmt_date(ts, time_=False) -> str:
    if not ts:
        return "—"
    return dt.datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M" if time_ else "%d/%m/%Y")


if __name__ == "__main__":
    if not IS_MAC and os.environ.get("STATION_ALLOW_LINUX") != "1":
        print("O GPlanSiS|Station funciona no macOS.")
        sys.exit(1)
    sys.exit(cli(sys.argv[1:]))
