#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════════════════════╗
║                    PAG - Pagan Linux Package Manager v3.3.7                  ║
║         Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n            ║
╚══════════════════════════════════════════════════════════════════════════════╝

KLUCZOWE CECHY:
  - Atomowa instalacja przez staging (tmpdir → rename) – brak pół-instalacji
  - Bezpieczne usuwanie – sprawdza czy plik nie jest współdzielony
  - SQLite dla bazy plików – miliony plików bez problemu
  - GPG: weryfikacja repo.json + podpisy pakietów + pinning fingerprintu
  - Hooki: pre/post-install, pre/post-remove (piaskownica env, timeout, audit)
  - Głęboka weryfikacja SHA256 per-plik
  - Pełny rollback – cofa fizyczne pliki
  - Blokada flock – tylko jedna instancja
  - Transakcje z migawkami + rejestr wykonanych hooków
  - Cache HTTP (ETag/If-Modified-Since)
  - Wielojęzyczność (i18n) – PL, EN

FORMAT PAKIETU (.pag):
  ├── data.tar.xz        – pliki + sums.json (SHA256 per plik)
  ├── metadata.json      – nazwa, wersja, zależności
  └── hooks/             – pre-install, post-install, pre-remove, post-remove

MODEL ZAUFANIA / BEZPIECZEŃSTWO:
  - Repozytorium MUSI być zaufane: podpisy GPG zweryfikowane; fingerprint
    klucza przypiętego do repo (TOFU przy pierwszym użyciu, potem pinning).
  - Hooki uruchamiają dowolny plik z pakietu jako ROOT (jak apt/pacman).
    Ograniczamy je (czyste env, timeout, PAG_NO_HOOKS=1, log do
    /var/log/pag/audit.log) i rejestrujemy w transakcji, ale ostatecznie
    instalujesz kod, któremu ufasz.
  - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
"""

import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re

# Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
# pag ships, otherwise urlopen() fails with "unable to get local issuer
# certificate" (no default capath/cafile is resolved in the chroot).
for _cafile in (
    "/etc/ssl/certs/ca-certificates.crt",
    "/etc/ssl/cert.pem",
):
    if os.path.isfile(_cafile):
        os.environ["SSL_CERT_FILE"] = _cafile
        break

from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Set
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.request import urlopen, Request
import threading, itertools

# Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
PAG_VERSION = "3.3.7"
from urllib.error import URLError, HTTPError

# =============================================================================
# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
# =============================================================================

class ProgressBar:
    """Czysty Python progress bar — działa z TTY i bez."""
    def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
        self.total = max(total, 1)
        self.desc = desc
        self.unit = unit
        self.width = width
        self.n = 0
        self.start = time.time()
        self.tty = sys.stderr.isatty()
        self._last_line_len = 0

    def update(self, n: Optional[int] = None, suffix: str = ""):
        if n is not None:
            self.n = n
        else:
            self.n += 1
        pct = self.n / self.total * 100
        elapsed = time.time() - self.start
        speed = self.n / elapsed if elapsed > 0 else 0
        if self.n >= self.total:
            eta_str = "done"
        elif speed > 0:
            eta = (self.total - self.n) / speed
            eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
        else:
            eta_str = "?..."
        bar_len = int(self.width * pct / 100)
        bar = "█" * bar_len + "░" * (self.width - bar_len)
        line = f"  {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
        if self.tty:
            # Overwrite current line
            clear = " " * max(0, self._last_line_len - len(line))
            print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
            self._last_line_len = len(line)
        else:
            # Print milestone lines only (every 10% or when done)
            if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
                print(line, file=sys.stderr)

    def close(self):
        if self.tty:
            print(file=sys.stderr)
            self._last_line_len = 0

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()


class DownloadBar:
    """Pasek postępu pobierania — na podstawie Content-Length."""
    def __init__(self, filename: str, total_bytes: int):
        self.filename = filename
        self.total = total_bytes
        self.downloaded = 0
        self.start = time.time()
        self.tty = sys.stderr.isatty()
        self._last_len = 0

    def update(self, chunk_size: int):
        self.downloaded += chunk_size
        if self.total <= 0:
            return
        pct = self.downloaded / self.total * 100
        elapsed = time.time() - self.start
        speed = self.downloaded / elapsed if elapsed > 0 else 0
        if speed > 0:
            eta = (self.total - self.downloaded) / speed
            eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
        else:
            eta_str = "?..."
        bar_len = 25
        filled = int(bar_len * pct / 100)
        bar = "█" * filled + "░" * (bar_len - filled)
        sz = self._fmt_size(self.total)
        spd = self._fmt_size(int(speed))
        line = f"  ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
        if self.tty:
            clear = " " * max(0, self._last_len - len(line))
            print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
            self._last_len = len(line)

    def close(self):
        if self.tty and self.total > 0:
            print(file=sys.stderr)

    @staticmethod
    def _fmt_size(n: int) -> str:
        for unit in ("B", "KB", "MB", "GB"):
            if n < 1024:
                return f"{n:.1f} {unit}"
            n /= 1024
        return f"{n:.1f} TB"

# =============================================================================
# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
# =============================================================================

GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
GPG_HOME = "/etc/pag/gpg"  # izolowany keyring (działa z keyboxd GPG 2.4+)

def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
    """
    Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
    gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
    Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
    (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
    """
    env = kwargs.pop("env", None) or os.environ.copy()
    env["LC_ALL"] = "C"
    env["GNUPGHOME"] = GPG_HOME
    try:
        return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
    except FileNotFoundError:
        # GPG nie jest dostępne – zwróć błąd z komunikatem
        return subprocess.CompletedProcess(
            [GPG_BINARY, *args], 127,
            stdout=b"", stderr=f"GPG binary not found ({GPG_BINARY})".encode()
        )
    except subprocess.TimeoutExpired:
        return subprocess.CompletedProcess(
            [GPG_BINARY, *args], 124,
            stdout=b"", stderr=b"GPG operation timed out"
        )

def _load_trust_db() -> dict:
    """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
    try:
        with open(TRUST_DB) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def _save_trust_db(db: dict):
    os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
    with open(TRUST_DB, "w") as f:
        json.dump(db, f, indent=2)


def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
    """Weryfikuje podpis i odczytuje fingerprint podpisującego.

    Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
    """
    env = os.environ.copy()
    res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
                   capture_output=True, text=True, timeout=timeout, env=env)
    if res.returncode != 0:
        return False, None
    m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
    if not m:
        m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
    return True, (m.group(1).upper() if m else None)


# =============================================================================
# i18n – WIELOJĘZYCZNOŚĆ
# =============================================================================

LANG = os.environ.get("LANG", "en_US.UTF-8")[:2]  # pl, en, de...
COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()

def _c(code: str, text: str) -> str:
    """Dodaje kody ANSI jeśli kolor jest włączony."""
    if not COLOR:
        return text
    colors = {
        "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
        "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
        "reset": "\033[0m",
    }
    return f"{colors.get(code,'')}{text}{colors['reset']}"

T = {
    "en": {
        "root_required": "pag requires root privileges (sudo).",
        "db_locked": "Another pag instance is running.",
        "db_lock_hint": "If this is an error, remove: rm {}",
        "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
        "all_installed": "All packages are already installed.",
        "to_install": "To install: {} packages ({:.2f} MB)",
        "new": "NEW",
        "continue_q": "Continue? [Y/n] ",
        "cancelled": "Cancelled.",
        "not_found": "not found in repos",
        "downloading": "Downloading",
        "download_fail": "download failed",
        "gpg_fail": "GPG verification failed",
        "sha256_mismatch": "SHA256 mismatch",
        "installed": "Installed {} packages.",
        "rollback_restored": "Restored previous state from snapshot.",
        "rollback_files": "Rolled back {} files.",
        "no_history": "No transaction history.",
        "pinned_list": "Pinned packages ({}):",
        "no_pinned": "No pinned packages.",
        "pinned_to": "pinned to",
        "unpinned": "unpinned.",
        "not_pinned": "was not pinned.",
        "repo_added": "Added repository: {}",
        "repo_exists": "Repository already exists: {}",
        "updated_done": "Index refresh complete. {} packages cached.",
        "upgrading": "Upgrading: {} packages",
        "all_up_to_date": "All packages are up to date.",
        "removing": "Removing",
        "orphans_found": "Orphaned dependencies ({}): {}",
        "flatpak_missing": "Flatpak is not installed.",
        "flatpak_adding": "Adding Flathub remote...",
        "flatpak_searching": "Searching Flathub for '{}'...",
        "flatpak_found": "Found {} results:",
        "flatpak_not_found": "not found on Flathub",
        "flatpak_install_prompt": "Install {}? [Y/n] ",
        "flatpak_installing": "Installing {}...",
        "flatpak_installed": "Flatpak {} installed.",
        "flatpak_removed": "Flatpak {} removed.",
        "flatpak_not_installed": "Flatpak {} is not installed.",
        "flatpak_info_id": "ID",
        "flatpak_info_version": "Version",
        "flatpak_info_branch": "Branch",
        "flatpak_info_origin": "Origin",
        "flatpak_info_size": "Installed size",
        "flatpak_info_desc": "Description",
        "flatpak_updated": "Flatpaks updated.",
        "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
        "key_imported": "Key imported successfully.",
        "key_removed": "Key removed: {}",
        "no_keys": "No trusted GPG keys.",
        "verify_ok": "All {} files intact.",
        "verify_errors": "{} problems found:",
        "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
        "deployments_list": "Deployments ({}):",
        "no_deployments": "No deployments.",
        "active_deployment": "ACTIVE",
        "deploy_rollback_ok": "Switched to deployment: {}",
        "deploy_rollback_fail": "No previous deployment.",
        "deploy_cleanup_ok": "Removed {} old deployments.",
        "deploy_cleanup_none": "No deployments to clean (minimum {}).",
        "why_explicit": "explicitly installed",
        "why_dependency": "dependency of",
        "why_not_installed": "not installed",
        "autoremove_ok": "Removed {} orphaned packages.",
        "autoremove_none": "No orphaned packages.",
        "downloaded": "Downloaded {} to cache ({:.2f} MB).",
        "provides_mapped": "{} → {} (provides)",
        "stats_title": "PAG Statistics",
        "stats_packages": "Installed packages",
        "stats_files": "Tracked files",
        "stats_size": "Total size",
        "stats_cache": "Cache size",
        "stats_history": "Transactions",
        "stats_last_update": "Last update",
    },
    "pl": {
        "root_required": "pag wymaga uprawnień root (sudo).",
        "db_locked": "Inna instancja pag jest uruchomiona.",
        "db_lock_hint": "Jeśli to błąd, usuń: rm {}",
        "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
        "all_installed": "Wszystkie pakiety są już zainstalowane.",
        "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
        "new": "NOWY",
        "continue_q": "Kontynuować? [T/n] ",
        "cancelled": "Anulowano.",
        "not_found": "brak w repozytoriach",
        "downloading": "Pobieranie",
        "download_fail": "błąd pobierania",
        "gpg_fail": "błąd weryfikacji GPG",
        "sha256_mismatch": "niezgodność SHA256",
        "installed": "Zainstalowano {} pakietów.",
        "rollback_restored": "Przywrócono poprzedni stan z migawki.",
        "rollback_files": "Wycofano {} plików.",
        "no_history": "Brak historii transakcji.",
        "pinned_list": "Przypięte pakiety ({}):",
        "no_pinned": "Brak przypiętych pakietów.",
        "pinned_to": "przypięty do",
        "unpinned": "odpięty.",
        "not_pinned": "nie był przypięty.",
        "repo_added": "Dodano repozytorium: {}",
        "repo_exists": "Repozytorium już istnieje: {}",
        "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
        "upgrading": "Aktualizacje: {} pakietów",
        "all_up_to_date": "Wszystkie pakiety są aktualne.",
        "removing": "Usuwanie",
        "orphans_found": "Osierocone zależności ({}): {}",
        "flatpak_missing": "Flatpak nie jest zainstalowany.",
        "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
        "flatpak_searching": "Szukam '{}' we Flathub...",
        "flatpak_found": "Znaleziono {} wyników:",
        "flatpak_not_found": "nie znaleziono we Flathub",
        "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
        "flatpak_installing": "Instalowanie {}...",
        "flatpak_installed": "Flatpak {} zainstalowany.",
        "flatpak_removed": "Flatpak {} usunięty.",
        "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
        "flatpak_info_id": "ID",
        "flatpak_info_version": "Wersja",
        "flatpak_info_branch": "Gałąź",
        "flatpak_info_origin": "Źródło",
        "flatpak_info_size": "Rozmiar",
        "flatpak_info_desc": "Opis",
        "flatpak_updated": "Flapaki zaktualizowane.",
        "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
        "key_imported": "Klucz zaimportowany pomyślnie.",
        "key_removed": "Klucz usunięty: {}",
        "no_keys": "Brak zaufanych kluczy GPG.",
        "verify_ok": "Wszystkie {} plików sprawne.",
        "verify_errors": "Znaleziono {} problemów:",
        "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
        "deployments_list": "Deploymenty ({}):",
        "no_deployments": "Brak deploymentów.",
        "active_deployment": "AKTYWNY",
        "deploy_rollback_ok": "Przełączono na deployment: {}",
        "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
        "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
        "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
        "why_explicit": "zainstalowany jawnie",
        "why_dependency": "zależność od",
        "why_not_installed": "niezainstalowany",
        "autoremove_ok": "Usunięto {} osieroconych pakietów.",
        "autoremove_none": "Brak osieroconych pakietów.",
        "downloaded": "Pobrano {} do cache ({:.2f} MB).",
        "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
        "sec_suid": "SUID stripped from {path}",
        "sec_https": "HTTPS required for repos",
        "sec_badname": "Invalid package name: {name}",
        "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
        "sec_conflict": "File conflict: {path} owned by {owner}",
        "sec_audit": "{pkg} installed by {user}",
        "sec_locked": "Another pag process is running",
        "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
        "sec_suid_pl": "SUID usuniety z {path}",
        "sec_https_pl": "Repozytorium wymaga HTTPS",
        "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
        "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
        "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
        "sec_audit_pl": "{pkg} zainstalowany przez {user}",
        "sec_locked_pl": "Inny proces pag juz dziala",

        "provides_mapped": "{} → {} (provides)",
        "stats_title": "Statystyki PAG",
        "stats_packages": "Zainstalowane pakiety",
        "stats_files": "Śledzone pliki",
        "stats_size": "Całkowity rozmiar",
        "stats_cache": "Rozmiar cache",
        "stats_history": "Transakcje",
        "stats_last_update": "Ostatnia aktualizacja",
    },
}

def _(key: str, *args, **kwargs) -> str:
    """Tłumaczy klucz i formatuje argumenty."""
    msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
    if args or kwargs:
        return msg.format(*args, **kwargs)
    return msg

# =============================================================================
# ŚCIEŻKI
# =============================================================================
PAG_ROOT      = os.environ.get("PAG_ROOT", "/")
PAG_DB        = "/var/lib/pag"
PAG_CACHE     = "/var/cache/pag"
PAG_CONF      = "/etc/pag"
REPO_CACHE    = "/var/cache/pag/repos"
REPOS_CONF    = "/etc/pag/repos.conf"
REPOS_DIR     = PAG_CONF + "/repos"   # drop-in: /etc/pag/repos/<nazwa>.conf
INSTALLED_DB  = "/var/lib/pag/installed.json"
FILES_DB_SQL  = "/var/lib/pag/files.db"       # SQLite!
WORLD_FILE    = "/var/lib/pag/world"
PINNED_FILE   = "/var/lib/pag/pinned.json"
HISTORY_FILE  = "/var/lib/pag/history.json"
LOCK_FILE     = "/var/lib/pag/pag.lock"
STAGING_DIR   = "/.pag_staging"          # na tej samej partycji co / (unikamy EXDEV)
PKG_EXT       = ".pag"
REPO_CACHE_TTL = 3600
MAX_PKG_SIZE  = 2 * 1024 * 1024 * 1024  # 2 GB – maksymalny rozmiar paczki
ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')

# Bezpieczeństwo / audyt
AUDIT_LOG      = "/var/log/pag/audit.log"      # dziennik operacji krytycznych (hooki, self-update)
TRUST_DB       = "/etc/pag/trusted.json"        # mapa repo_url → fingerprint klucza podpisującego
HOOK_API_VERSION = "1"                          # wersjonowane API hooków (env PKG_HOOK_API)
MAX_PKG_SIZE  = 2 * 1024 * 1024 * 1024  # 2 GB – maksymalny rozmiar paczki

# =============================================================================
# IMMUTABLE OS – DEPLOYMENTY
# =============================================================================
# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
# /var, /etc, /home są współdzielone między deploymentami.
#
# STRUKTURA:
#   /.deployments/
#     active → 20260723T120000       (symlink do aktywnego)
#     20260723T120000/
#       usr/ bin/ lib/ lib64/ ...    (pełny system)
#       var → /var                   (symlink do współdzielonego)
#       etc → /etc
#       home → /home
#     ...
#
# Jak to działa:
#   1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
#   2. pag remove  → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
#   3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
#   4. Przy starcie systemu: initrd montuje /.deployments/active jako /
# =============================================================================

DEPLOYMENTS_DIR  = "/.deployments"
ACTIVE_LINK      = "/.deployments/active"
DEPLOYMENTS_DB   = "/var/lib/pag/deployments.json"

# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
SHARED_PATHS = {
    "/var", "/etc", "/home", "/root", "/tmp", "/run",
    "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
    "/.deployments", "/.pag_staging",
}

def _is_shared_path(rel: str) -> bool:
    """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
    for sp in SHARED_PATHS:
        if rel == sp or rel.startswith(sp + "/"):
            return True
    return False

def _get_deployment_root() -> str:
    """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
    if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
        return PAG_ROOT
    if os.path.islink(ACTIVE_LINK):
        return os.readlink(ACTIVE_LINK)
    if os.path.isdir(ACTIVE_LINK):
        return ACTIVE_LINK
    # Brak deploymentów – użyj /
    return PAG_ROOT

def _load_deployments() -> List[dict]:
    """Wczytuje historię deploymentów."""
    if not os.path.exists(DEPLOYMENTS_DB):
        return []
    try:
        return json.load(open(DEPLOYMENTS_DB))
    except Exception:
        return []

def _save_deployments(deployments: List[dict]):
    os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
    json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)

def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
    """
    Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
    Zwraca (deployment_dir, deployment_id).
    """
    deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
    deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
    os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)

    active = _get_deployment_root()

    if os.path.isdir(active) and active != PAG_ROOT:
        # Trójstopniowa strategia kopiowania deploymentu:
        # 1. reflink (CoW – btrfs, xfs)         → 0 MB kopiowane
        # 2. hardlink (linki twarde)             → 0 MB kopiowane, tylko inody
        # 3. zwykłe cp (ostateczność)            → pełna kopia
        print(f"  ⚡ Kopiowanie aktywnego deploymentu...")
        copied = False
        for method, cmd, label in [
            ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
            ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
            ("copy",    ["cp", "-a",  active + "/.", deploy_dir + "/"], "pełna kopia"),
        ]:
            try:
                subprocess.run(cmd, check=True, timeout=600, capture_output=True)
                print(f"  ✅ Deployment: {deploy_id} ({label})")
                copied = True
                break
            except subprocess.CalledProcessError:
                if method == "copy":
                    raise  # ostatnia deska – niech leci wyjątek
                continue
        if not copied:
            raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
    else:
        # Pierwszy deployment – tylko katalogi szkieletowe
        for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
            if os.path.isdir(d):
                dest = os.path.join(deploy_dir, d.lstrip("/"))
                os.makedirs(dest, exist_ok=True)
        print(f"  ✅ Pierwszy deployment: {deploy_id}")

    # Utwórz symlinki do współdzielonych katalogów
    for sp in SHARED_PATHS:
        link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
        if not os.path.lexists(link_dst) and os.path.isdir(sp):
            os.symlink(sp, link_dst)

    # Zapisz w bazie deploymentów
    deployments = _load_deployments()
    deployments.append({
        "id": deploy_id,
        "action": action,
        "packages": pkg_names,
        "timestamp": datetime.now().isoformat(),
        "active": True,
    })
    # Oznacz poprzednie jako nieaktywne
    for d in deployments[:-1]:
        d["active"] = False
    _save_deployments(deployments)

    return deploy_dir, deploy_id

def _switch_deployment(deploy_dir: str) -> bool:
    """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
    tmp_link = ACTIVE_LINK + ".new"
    if os.path.lexists(tmp_link):
        os.remove(tmp_link)
    os.symlink(deploy_dir, tmp_link)
    os.rename(tmp_link, ACTIVE_LINK)  # atomowe na tym samym FS
    return True

DEFAULT_REPOS = [
    "https://repo.paganlinux.eu/stable/",
]

# =============================================================================
# INICJALIZACJA
# =============================================================================

def ensure_dirs():
    for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
        os.makedirs(d, exist_ok=True)
    for f, default in [
        (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
        (INSTALLED_DB, "{}"),
        (PINNED_FILE, "{}"),
        (HISTORY_FILE, "[]"),
    ]:
        if not os.path.exists(f):
            with open(f, "w") as fh: fh.write(default)
    if not os.path.exists(WORLD_FILE):
        Path(WORLD_FILE).touch()
    if not os.path.exists(GPG_HOME):
        os.makedirs(GPG_HOME, exist_ok=True)
        os.chmod(GPG_HOME, 0o700)
        _gpg_run("--list-keys", capture_output=True)
    # Inicjalizuj SQLite
    _db_init()
    # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
    if os.path.isdir(STAGING_DIR):
        for entry in os.listdir(STAGING_DIR):
            if entry == "backups":
                continue  # backupy starych wersji – potrzebne do `pag rollback`
            path = os.path.join(STAGING_DIR, entry)
            try:
                if os.path.isfile(path) or os.path.islink(path):
                    os.unlink(path)
                elif os.path.isdir(path):
                    shutil.rmtree(path, ignore_errors=True)
            except OSError:
                pass

# =============================================================================
# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
# =============================================================================

from contextlib import contextmanager

@contextmanager
def _db_session():
    """Context manager – gwarantuje zamknięcie połączenia."""
    conn = sqlite3.connect(FILES_DB_SQL)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=NORMAL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def _db_init():
    """Tworzy tabele SQLite jeśli nie istnieją."""
    with _db_session() as db:
        db.execute("""
            CREATE TABLE IF NOT EXISTS files (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                path TEXT NOT NULL,
                package TEXT NOT NULL,
                sha256 TEXT,
                size INTEGER,
                is_symlink INTEGER DEFAULT 0,
                symlink_target TEXT,
                UNIQUE(path, package)
            )
        """)
        db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
        db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
        db.execute("""
            CREATE TABLE IF NOT EXISTS file_checksums (
                path TEXT PRIMARY KEY,
                sha256 TEXT NOT NULL,
                installed_at TEXT
            )
        """)
        db.commit()

def _db_record_files(pkg_name: str, files: List[dict]):
    """Zapisuje pliki do SQLite (obsługuje symlinki)."""
    with _db_session() as db:
        # Context manager sam zarządza transakcją atomowo
        db.executemany(
            "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
            "VALUES (?,?,?,?,?,?)",
            [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
              f.get("is_symlink", 0), f.get("symlink_target", ""))
             for f in files]
        )
        db.executemany(
            "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
            [(f["path"], f.get("sha256",""), datetime.now().isoformat())
             for f in files if f.get("sha256")]
        )

def _db_get_package_files(pkg_name: str) -> List[str]:
    with _db_session() as db:
        return [r["path"] for r in db.execute(
            "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
        )]

def _db_get_file_owners(filepath: str) -> List[str]:
    """Zwraca listę pakietów będących właścicielami pliku."""
    with _db_session() as db:
        return [r["package"] for r in db.execute(
            "SELECT package FROM files WHERE path=?", (filepath,)
        )]

def _db_remove_package_files(pkg_name: str):
    with _db_session() as db:
        db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
        db.commit()

def _db_get_all_file_checksums() -> Dict[str, str]:
    with _db_session() as db:
        return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}

def _db_count_files() -> int:
    with _db_session() as db:
        return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]

# =============================================================================
# BLOKADA
# =============================================================================

class DatabaseLock:
    """Blokada oparta na PID-file – niezawodna, bez flock."""
    def __init__(self):
        self._acquired = False
    def __enter__(self):
        os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
        if os.path.exists(LOCK_FILE):
            try:
                old_pid = int(open(LOCK_FILE).read().strip())
                os.kill(old_pid, 0)  # sygnał 0 = sprawdź czy proces żyje
            except (ValueError, OSError, ProcessLookupError):
                # Stary PID nie żyje – usuwamy nieświeżą blokadę
                try:
                    os.remove(LOCK_FILE)
                except OSError:
                    pass
            else:
                print(f"❌ {_('db_locked')}", file=sys.stderr)
                print(f"   {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
                sys.exit(1)
        with open(LOCK_FILE, "w") as f:
            f.write(str(os.getpid()))
        self._acquired = True
        return self
    def __exit__(self, *args):
        if self._acquired:
            try:
                os.remove(LOCK_FILE)
            except OSError:
                pass

# =============================================================================
# POMOCNICZE
# =============================================================================


_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
                     "/boot/", "/lib/",  # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
                     # Pliki wewnętrzne paczki .pkg.tar.xz
                     "metadata.json", "data.tar.xz", "hooks/",
                     "sums.json")

def _check_path_safety(name: str) -> bool:
    # Normalizuj – usuń leading ./
    if name.startswith("./"):
        name = name[2:]
    if name in (".", ""):
        return True
    # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
    # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
    norm = name.lstrip("/")
    for prefix in _ALLOWED_PREFIXES:
        p = prefix.lstrip("/").rstrip("/")
        if norm == p or norm.startswith(p + "/"):
            return True
    return False


def _validate_pkg_name(name):
    return bool(ALLOWED_PKG_RE.match(name))



def _audit(msg):
    from datetime import datetime, timezone
    os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
    with open(AUDIT_LOG, "a") as f:
        f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")

def _strip_suid(path):
    try:
        st = os.stat(path)
        if st.st_mode & 0o4000:
            os.chmod(path, st.st_mode & ~0o4000)
            print(f"  {_("sec_suid", path=path)}")
    except OSError:
        pass

def _check_downgrade(pkg_name, new_ver, installed_db):
    if pkg_name in installed_db:
        old = installed_db[pkg_name].get("version", "0")
        if new_ver < old:
            print(f"  {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
            return False
    return True

def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
    """
    Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.

    Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
    oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
    zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.

    Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
    prostym sprawdzaniu stringa:
      - Blokuje ścieżki absolutne i z '..' (path traversal)
      - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
      - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
        rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
      - Zachowuje oryginalne uprawnienia plików
    """
    dest_real = os.path.realpath(dest)
    os.makedirs(dest_real, exist_ok=True)

    def _target_within(path: str) -> bool:
        try:
            return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
        except ValueError:
            # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
            return False

    for member in tar.getmembers():
        name = member.name

        # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
        if name.startswith('/'):
            continue
        if '..' in name.split('/'):
            continue
        # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
        if '\x00' in name or '\\' in name:
            continue
        if not _check_path_safety(name):
            print(f"  BLOCKED: {name}")
            continue

        target = os.path.join(dest, name)

        # --- Ochrona na podstawie finalnej ścieżki ---
        # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
        # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
        if not _target_within(target):
            print(f"  BLOCKED (escape): {name}")
            continue

        # --- Ochrona dla symlinków i hardlinków ---
        if member.issym() or member.islnk():
            link = member.linkname
            # Szybkie odrzucenie linków absolutnych / z '..'
            if link.startswith('/') or '..' in link.split('/'):
                continue
            # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
            link_target = os.path.join(os.path.dirname(target), link)
            if not _target_within(link_target):
                print(f"  BLOCKED (link escape): {name} -> {link}")
                continue

        # Rozpakuj z zachowaniem metadanych
        try:
            tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
        except Exception as e:
            print(f"  ⚠ Nie rozpakowano {name}: {e}")
            continue
        _strip_suid(target)


def _sha256_file(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

def _split_version(v: str):
    """Rozdziela wersję na (release_parts, prerelease_parts).

    Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
    """
    v = v.strip().lower().lstrip("v")
    # build metadata po '+' jest ignorowane przy porównywaniu (semver)
    v = v.split("+", 1)[0]
    # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
    if "-" in v:
        rel, pre = v.split("-", 1)
    elif "_" in v:
        rel, pre = v.split("_", 1)
    else:
        rel, pre = v, ""
    nums = []
    for part in rel.split("."):
        m = re.match(r"(\d+)", part)
        nums.append(int(m.group(1)) if m else 0)
    pre_parts = [p for p in pre.split(".") if p]
    return nums, pre_parts


def _cmp_pre(a, b):
    """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
    for i in range(max(len(a), len(b))):
        if i >= len(a):
            return -1  # krótszy prerelease jest niższy
        if i >= len(b):
            return 1
        ia, ib = a[i], b[i]
        if ia == ib:
            continue
        na, nb = ia.isdigit(), ib.isdigit()
        if na and nb:
            return 1 if int(ia) > int(ib) else -1
        if na != nb:
            return -1 if na else 1  # identyfikator liczbowy < alfanumeryczny
        return 1 if ia > ib else -1
    return 0


def _cmp_version(a: str, b: str) -> int:
    """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
    a_rel, a_pre = _split_version(a)
    b_rel, b_pre = _split_version(b)
    # Porównaj część release (brakujące komponenty traktuj jako 0)
    for i in range(max(len(a_rel), len(b_rel))):
        xa = a_rel[i] if i < len(a_rel) else 0
        xb = b_rel[i] if i < len(b_rel) else 0
        if xa != xb:
            return 1 if xa > xb else -1
    # Część release równa → decyduje prerelease.
    # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
    if not a_pre and not b_pre:
        return 0
    if not a_pre:
        return 1
    if not b_pre:
        return -1
    return _cmp_pre(a_pre, b_pre)


def _version_newer(a: str, b: str) -> bool:
    """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
    try:
        return _cmp_version(a, b) > 0
    except Exception:
        return a != b

def load_json(path):
    try:
        with open(path) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}

def save_json(path, data):
    with open(path, "w") as f:
        json.dump(data, f, indent=2)

class PackageInfo:
    __slots__ = ("name","version","description","dependencies",
                 "size_bytes","sha256","gpg_fp","repo_url","filename","provides")
    def __init__(self, d, repo=""):
        self.name = d.get("name","?")
        self.version = d.get("version","0")
        self.description = d.get("description","")
        self.dependencies = d.get("dependencies",[])
        self.size_bytes = d.get("size",0)
        self.sha256 = d.get("sha256","")
        self.gpg_fp = d.get("gpg_fingerprint","")
        self.repo_url = repo
        self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
        self.provides = d.get("provides", []) or []

# =============================================================================
# REPOZYTORIA (cache, ETag, GPG)
# =============================================================================

def _parse_repos_config():
    """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.

    Format linii: <url> [fingerprint]
    Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
    podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
    pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.

    Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
    wygodne dodawanie repo bez dotykania głównego repos.conf
    (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
    """
    entries = []

    def _read_lines(path):
        if not os.path.exists(path):
            return
        for line in open(path):
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            parts = line.split()
            url = parts[0].rstrip("/")
            fp = parts[1].lower() if len(parts) > 1 else ""
            entries.append({"url": url, "fingerprint": fp or None})

    # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
    _read_lines(REPOS_CONF)
    # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
    if os.path.isdir(REPOS_DIR):
        for drop in sorted(os.listdir(REPOS_DIR)):
            if drop.endswith(".conf"):
                _read_lines(os.path.join(REPOS_DIR, drop))

    # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
    seen, unique = set(), []
    for e in entries:
        if e["url"] not in seen:
            seen.add(e["url"])
            unique.append(e)

    if not unique:
        for url in DEFAULT_REPOS:
            unique.append({"url": url, "fingerprint": None})
    return unique


def get_repos():
    return [e["url"] for e in _parse_repos_config()]


def _repo_pinned_fp(repo_url):
    """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
    by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
    if by_url.get(repo_url):
        return by_url[repo_url]
    db = _load_trust_db()
    fp = db.get(repo_url)
    return fp.lower() if fp else None

def _repo_cache_path(url):
    return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")

def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"

def fetch_repo_index(repo_url, force=False):
    cp = _repo_cache_path(repo_url)
    ep = _repo_etag_path(repo_url)
    tp = _repo_ts_path(repo_url)

    if not force and os.path.exists(cp) and os.path.exists(tp):
        try:
            if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
                return json.load(open(cp)).get("packages",[])
        except: pass

    headers = {"User-Agent": "pag/3.0"}
    if os.path.exists(tp) and not force:
        try:
            lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
            # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
            try:
                old_locale = locale.setlocale(locale.LC_TIME)
                locale.setlocale(locale.LC_TIME, 'C')
                headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
                locale.setlocale(locale.LC_TIME, old_locale)
            except (locale.Error, ValueError):
                # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
                headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
        except: pass
    if os.path.exists(ep) and not force:
        try: headers["If-None-Match"] = open(ep).read().strip()
        except: pass

    try:
        req = Request(f"{repo_url}/repo.json", headers=headers)
        with urlopen(req, timeout=30) as resp:
            etag = resp.headers.get("ETag","")
            if etag: open(ep,"w").write(etag)
            raw = resp.read()
            data = json.loads(raw.decode())
            # Zapisuj SUROWE bajty (nie re-serializuj!) – podpis GPG jest nad
            # oryginalnymi bajtami repo.json z serwera
            with open(cp,"wb") as f: f.write(raw)
            open(tp,"w").write(str(time.time()))
            # SPRAWDŹ WYNIK WERYFIKACJI – nie ignoruj!
            if not _verify_repo_sig(repo_url, cp):
                return None  # weryfikacja nie powiodła się, cache usunięty
            return data.get("packages",[])
    except HTTPError as e:
        if e.code == 304:
            open(tp,"w").write(str(time.time()))
            if os.path.exists(cp):
                return json.load(open(cp)).get("packages",[])
        print(f"  ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
        return None
    except Exception as e:
        print(f"  ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
        if os.path.exists(cp):
            try: return json.load(open(cp)).get("packages",[])
            except Exception: pass
        return None

def _verify_repo_sig(repo_url, cache_path) -> bool:
    """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.

    FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
    Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.

    Model zaufania (TOFU + pinning):
      - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
        a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
        To świadomy kompromis wygody i bezpieczeństwa.
      - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
        Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
          pag key-trust <repo_url>  (po weryfikacji nowego klucza)
    """
    insecure = os.environ.get("PAG_INSECURE", "") == "1"

    if not os.path.exists(GPG_HOME):
        if insecure:
            return True  # brak GPG home – tryb insecure, akceptuj
        print(f"  ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
        print(f"     Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
        os.remove(cache_path)
        return False

    sig_path = cache_path + ".sig"
    # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
    sig_data = None
    sig_ext = ""
    for ext in (".asc", ".sig"):
        try:
            req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
            with urlopen(req, timeout=15) as resp:
                sig_data = resp.read()
            sig_ext = ext
            break
        except Exception:
            continue
    if not sig_data:
        if insecure:
            return True  # tryb insecure – akceptuj bez podpisu
        print(f"  ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
        print(f"     Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
        os.remove(cache_path)
        return False
    sig_path = cache_path + sig_ext
    with open(sig_path, "wb") as f:
        f.write(sig_data)

    ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
    if not ok:
        # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
        # jak apt) – gdy w keyringu brakuje klucza (No public key).
        res = _gpg_run("--verify", sig_path, cache_path,
                       capture_output=True, text=True, timeout=30)
        _stderr = (res.stderr or "")
        if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
            try:
                with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
                    keydata = r.read()
                with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
                    tmp.write(keydata)
                    tmp.flush()
                    _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
                    os.unlink(tmp.name)
                print(f"  🔑 Importowano klucz repo z {repo_url}/paganos.asc")
                ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
            except Exception:
                pass
        if not ok:
            if insecure:
                print(f"  ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
                return True
            os.remove(cache_path)
            print(f"  ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
            return False

    # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
    pinned = _repo_pinned_fp(repo_url)
    if pinned:
        if not fingerprint:
            if insecure:
                print(f"  ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
                return True
            os.remove(cache_path)
            print(f"  ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
            print(f"     Przypięty klucz: {pinned} – odrzucam indeks.")
            return False
        if fingerprint != pinned.upper():
            if insecure:
                print(f"  ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
                return True
            os.remove(cache_path)
            print(f"  ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
            print(f"     Oczekiwany: {pinned}")
            print(f"     Otrzymany:  {fingerprint}")
            print(f"     Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
            return False
        return True

    if fingerprint:
        # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
        db = _load_trust_db()
        if db.get(repo_url) != fingerprint:
            _save_trust_db({**db, repo_url: fingerprint})
            print(f"  🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
            print(f"     (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
            print(f"     Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
    return True

def fetch_all_packages(force=False):
    all_pkgs = {}
    for repo_url in get_repos():
        pkgs = fetch_repo_index(repo_url, force)
        if pkgs:
            for pdata in pkgs:
                name = pdata.get("name", pdata.get("filename","?").split("-")[0])
                pkg = PackageInfo(pdata, repo_url)
                if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
                    all_pkgs[name] = pkg
    return all_pkgs

# =============================================================================
# GPG
# =============================================================================

def _verify_pkg_gpg(pkg_path, repo_url=None):
    """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.

    FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
    Zwraca (passed: bool, message: str).
    """
    insecure = os.environ.get("PAG_INSECURE", "") == "1"
    sig_path = pkg_path + ".sig"
    if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
        sig_path = pkg_path + ".asc"

    if not os.path.exists(sig_path):
        if insecure:
            return True, "(no signature – PAG_INSECURE)"
        return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"

    ok, fp = _gpg_verify_fp(sig_path, pkg_path)
    if not ok:
        if insecure:
            return True, "(invalid signature – PAG_INSECURE)"
        return False, "NIEPRAWIDŁOWY PODPIS GPG"

    # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
    if repo_url:
        pinned = _repo_pinned_fp(repo_url)
        if pinned and fp and fp != pinned.upper():
            if insecure:
                return True, "(pkg signer mismatch – PAG_INSECURE)"
            return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"

    return True, "GPG verified"

def cmd_key_add(source):
    ensure_dirs()
    if source.startswith("http"):
        try:
            with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
                keydata = resp.read()
            with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
                tmp.write(keydata); tmp.flush()
                _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
                os.unlink(tmp.name)
        except Exception as e:
            print(f"❌ Download error: {e}"); return 1
    else:
        _gpg_run("--import", source, capture_output=True, timeout=30)
    print(f"✅ {_('key_imported')}")

def cmd_key_list():
    if not os.path.exists(GPG_HOME):
        print(_("no_keys")); return
    result = _gpg_run("--list-keys", "--keyid-format", "LONG",
                       capture_output=True, text=True, timeout=30)
    print(result.stdout or _("no_keys"))

def cmd_key_remove(key_id):
    _gpg_run("--batch", "--yes", "--delete-key", key_id,
              capture_output=True, timeout=30)
    print(f"✅ {_('key_removed', key_id)}")

def _repo_signer_fp(repo_url):
    """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
    repo_url = repo_url.rstrip("/")
    try:
        with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
            data = r.read()
    except Exception:
        return None
    sig = None
    sig_ext = ".asc"
    for ext in (".asc", ".sig"):
        try:
            with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
                sig = r.read()
            sig_ext = ext
            break
        except Exception:
            continue
    if not sig:
        return None
    with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
        tf.write(data); tf.flush()
        data_path = tf.name
    sig_path = data_path + sig_ext
    try:
        with open(sig_path, "wb") as f:
            f.write(sig)
        ok, fp = _gpg_verify_fp(sig_path, data_path)
    finally:
        for p in (data_path, sig_path):
            try: os.unlink(p)
            except OSError: pass
    return fp if ok else None


def cmd_key_trust(repo_url):
    """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
    repo_url = repo_url.rstrip("/")
    print(f"🔐 Przypinam klucz repo {repo_url}...")
    fp = _repo_signer_fp(repo_url)
    if not fp:
        print("  ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
        print("     Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
        return 1
    db = _load_trust_db()
    _save_trust_db({**db, repo_url: fp})
    print(f"  ✅ Przypięto {fp} dla {repo_url}")
    print("     Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
    return 0


def cmd_key_untrust(repo_url):
    """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
    repo_url = repo_url.rstrip("/")
    db = _load_trust_db()
    if repo_url not in db:
        print(f"  ℹ {repo_url} nie ma przypiętego fingerprintu.")
        return 0
    del db[repo_url]
    _save_trust_db(db)
    print(f"  ✅ Usunięto przypięcie dla {repo_url}.")
    return 0


def cmd_key_trusted():
    """Listuje przypięte fingerprinty repozytoriów."""
    db = _load_trust_db()
    if not db:
        print(_("no_keys"))
        return
    for url, fp in sorted(db.items()):
        print(f"  {url}\n      {fp}")

# =============================================================================
# ATOMOWA INSTALACJA (STAGING)
# =============================================================================

def _safe_rename(src: str, dst: str) -> bool:
    """
    Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
    systemach plików (EXDEV), kopiuje + usuwa źródło.
    """
    try:
        os.rename(src, dst)
        return True
    except OSError as e:
        if e.errno == 18:  # EXDEV – cross-device link
            shutil.copy2(src, dst)
            os.remove(src)
            return True
        raise


def _install_file(src: str, rel: str, data_staging: str, sums: dict,
                   staging: str, journal: list, installed_files: list,
                   deploy_dir: str = "", backup_dir: str = "",
                   backup_journal: Optional[list] = None) -> bool:
    """
    Instaluje pojedynczy plik (zwykły lub symlink).
    Obsługuje: cross-device rename, symlinki, weryfikację SHA256.

    Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
    do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.

    Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
    stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
    """
    # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
    if deploy_dir and _is_shared_path("/" + rel):
        dst_root = PAG_ROOT
    elif deploy_dir:
        dst_root = deploy_dir
    else:
        dst_root = PAG_ROOT

    dst = os.path.join(dst_root, rel)

    # --- SYMLINK ---
    if os.path.islink(src):
        link_target = os.readlink(src)
        # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
        expected = sums.get("/" + rel, "")
        if expected:
            link_hash = hashlib.sha256(link_target.encode()).hexdigest()
            if expected and link_hash != expected:
                return False

        os.makedirs(os.path.dirname(dst), exist_ok=True)
        # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
        if backup_dir and backup_journal is not None and os.path.lexists(dst):
            try:
                backup_path = os.path.join(backup_dir, rel)
                os.makedirs(os.path.dirname(backup_path), exist_ok=True)
                os.replace(dst, backup_path)
                backup_journal.append((backup_path, "/" + rel))
                journal.append(("backup", backup_path, dst))
            except OSError:
                pass
        # Jeśli docelowy symlink już istnieje, usuń go
        if os.path.islink(dst) or os.path.exists(dst):
            os.remove(dst)
        os.symlink(link_target, dst)
        journal.append(("symlink", "", dst))
        installed_files.append({
            "path": "/" + rel,
            "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
            "size": len(link_target),
            "is_symlink": True,
            "symlink_target": link_target,
        })
        return True

    # --- ZWYKŁY PLIK ---
    # Oblicz SHA256
    try:
        file_sha = _sha256_file(src)
    except Exception:
        file_sha = ""

    # Weryfikuj sums.json
    expected = sums.get("/" + rel, "")
    if expected and file_sha and file_sha != expected:
        return False

    # Utwórz katalog docelowy
    os.makedirs(os.path.dirname(dst), exist_ok=True)

    # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
    if backup_dir and backup_journal is not None and os.path.lexists(dst):
        try:
            backup_path = os.path.join(backup_dir, rel)
            os.makedirs(os.path.dirname(backup_path), exist_ok=True)
            os.replace(dst, backup_path)
            backup_journal.append((backup_path, "/" + rel))
            journal.append(("backup", backup_path, dst))
        except OSError:
            pass

    # Atomowe przeniesienie (z fallbackiem dla cross-device).
    # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
    _safe_rename(src, dst)

    # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
    try:
        os.chown(dst, 0, 0)
    except (OSError, PermissionError):
        # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
        pass

    journal.append(("file", src, dst))
    installed_files.append({
        "path": "/" + rel,
        "sha256": file_sha,
        "size": os.path.getsize(dst),
        "is_symlink": False,
    })
    return True


def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
                    backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
    """
    Rozpakowuje do staging area, potem atomowo przenosi pliki.
    Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
    Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
    """
    staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
    journal = []
    installed_files = []
    backup_journal: List[Tuple[str, str]] = []

    try:
        # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
        with tarfile.open(pkg_path, "r:xz") as tf:
            _safe_extractall(tf, staging)

        data_tar = os.path.join(staging, "data.tar.xz")
        if not os.path.exists(data_tar):
            shutil.rmtree(staging, ignore_errors=True)
            return False, [], backup_journal

        # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
        data_staging = os.path.join(staging, "data")
        os.makedirs(data_staging, exist_ok=True)
        with tarfile.open(data_tar, "r:xz") as tf:
            _safe_extractall(tf, data_staging)

        # Wczytaj sums.json
        sums_path = os.path.join(data_staging, "sums.json")
        sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}

        # Hook pre-install (przed przeniesieniem plików do systemu)
        _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)

        # Przenieś pliki: staging/data/* → /
        for root, dirs, files in os.walk(data_staging):
            for fname in files:
                if fname == "sums.json":
                    continue
                src = os.path.join(root, fname)
                rel = os.path.relpath(src, data_staging)

                ok = _install_file(src, rel, data_staging, sums,
                                    staging, journal, installed_files, deploy_dir,
                                    backup_dir, backup_journal)
                if not ok:
                    # Cofnij wszystkie operacje
                    _rollback_journal(journal, staging)
                    return False, [], backup_journal

        # Uruchom hooki post-install
        hooks_dir = os.path.join(staging, "hooks")
        _run_hook(hooks_dir, "post-install", pkg)

        # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
        try:
            if os.path.isdir(hooks_dir):
                persisted = os.path.join(PAG_DB, "hooks", pkg.name)
                shutil.rmtree(persisted, ignore_errors=True)
                shutil.copytree(hooks_dir, persisted)
        except Exception:
            pass

        # Zapisz do SQLite
        _db_record_files(pkg.name, installed_files)

        shutil.rmtree(staging, ignore_errors=True)
        return True, installed_files, backup_journal

    except Exception as e:
        _rollback_journal(journal, staging)
        return False, [], backup_journal


def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
    """Odświeża cache ld.so po udanej instalacji pakietów."""
    ldconfig = shutil.which("ldconfig")
    if not ldconfig:
        print("  ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
              file=sys.stderr)
        return False

    target_root = deploy_dir or PAG_ROOT
    command = [ldconfig]
    if target_root != "/":
        command.extend(["-r", target_root])

    try:
        subprocess.run(command, check=True, timeout=60,
                       stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
                       text=True)
        return True
    except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
        detail = getattr(exc, "stderr", None) or str(exc)
        print(f"  ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
              file=sys.stderr)
        return False


def _rollback_journal(journal: list, staging_path: str):
    """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
    for entry in reversed(journal):
        op = entry[0]
        if op == "file":
            _, src, dst = entry
            try:
                if os.path.exists(dst) or os.path.islink(dst):
                    _safe_rename(dst, src)
            except Exception:
                pass
        elif op == "symlink":
            _, _, dst = entry
            try:
                if os.path.islink(dst) or os.path.exists(dst):
                    os.remove(dst)
            except Exception:
                pass
        elif op == "backup":
            # Przywróć starą wersję pliku z backupu (upgrade)
            _, bpath, dst = entry
            try:
                if os.path.lexists(bpath):
                    os.replace(bpath, dst)
            except Exception:
                pass
    shutil.rmtree(staging_path, ignore_errors=True)

# =============================================================================
# BEZPIECZNE USUWANIE
# =============================================================================

def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
    """
    Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
    Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
    """
    pkg_files = _db_get_package_files(pkg_name)
    removed = []
    skipped_shared = []

    for fpath in pkg_files:
        owners = _db_get_file_owners(fpath)
        # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
        other_owners = [o for o in owners if o != pkg_name and o in installed_db]

        if other_owners:
            # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
            skipped_shared.append(fpath)
            continue

        full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
        if os.path.isfile(full) or os.path.islink(full):
            os.remove(full)
            removed.append(fpath)

    # Usuń puste katalogi (od najgłębszych)
    dirs = set()
    for fpath in removed + skipped_shared:
        parent = os.path.dirname(fpath)
        while parent and parent != "/":
            dirs.add(parent)
            parent = os.path.dirname(parent)

    for d in sorted(dirs, key=len, reverse=True):
        full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
        if os.path.isdir(full_d):
            try:
                os.rmdir(full_d)
            except OSError:
                pass  # nie jest pusty – OK

    # Usuń z SQLite
    _db_remove_package_files(pkg_name)

    if skipped_shared:
        print(f"      ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")

    return len(removed) + len(skipped_shared), removed


def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
                        installed_db: dict, deploy_dir: str = "",
                        backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
    """
    Po upgrade usuwa pliki starej wersji, których nie ma w nowej.

    - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
      (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
    - Sprząta puste katalogi i wpisy SQLite starej wersji.
    Zwraca (liczba usuniętych, [usunięte ścieżki]).
    """
    new_set = set(new_paths)
    stale = [f for f in old_files if f not in new_set]
    if not stale:
        return 0, []

    root = deploy_dir or PAG_ROOT
    removed = []
    skipped = 0
    for fpath in stale:
        owners = _db_get_file_owners(fpath)
        other_owners = [o for o in owners if o != pkg_name and o in installed_db]
        if other_owners:
            # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
            skipped += 1
        else:
            full = os.path.join(root, fpath.lstrip("/"))
            if os.path.isfile(full) or os.path.islink(full):
                try:
                    if backup_dir and backup_journal is not None:
                        backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
                        os.makedirs(os.path.dirname(backup_path), exist_ok=True)
                        os.replace(full, backup_path)  # przenieś do backupu (rollback)
                        backup_journal.append((backup_path, fpath))
                    else:
                        os.remove(full)
                    removed.append(fpath)
                except OSError:
                    pass
        # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
        with _db_session() as db:
            db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))

    # Usuń puste katalogi (od najgłębszych)
    dirs = set()
    for fpath in removed:
        parent = os.path.dirname(fpath)
        while parent and parent != "/":
            dirs.add(parent)
            parent = os.path.dirname(parent)
    for d in sorted(dirs, key=len, reverse=True):
        full_d = os.path.join(root, d.lstrip("/"))
        if os.path.isdir(full_d):
            try:
                os.rmdir(full_d)
            except OSError:
                pass  # nie jest pusty – OK

    if removed:
        print(f"  🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
    if skipped:
        print(f"      ⚠ {skipped} plików współdzielonych zachowanych")

    return len(removed), removed


def _new_upgrade_backup_root() -> str:
    """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
    txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
    root = os.path.join(STAGING_DIR, "backups", txn)
    os.makedirs(root, exist_ok=True)
    return root


def _purge_old_backups(keep_root: str = ""):
    """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
    base = os.path.join(STAGING_DIR, "backups")
    if not os.path.isdir(base):
        return
    for entry in os.listdir(base):
        p = os.path.join(base, entry)
        if p != keep_root and os.path.isdir(p):
            shutil.rmtree(p, ignore_errors=True)

# =============================================================================
# HOOKI
# =============================================================================
# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
# Aby ograniczyć ryzyko:
#   - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
#   - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
#   - każde uruchomienie jest logowane do /var/log/pag/audit.log
#   - hook ma wersjonowane API (PKG_HOOK_API)
# =============================================================================

# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
_HOOKS_RUN: List[str] = []


def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
    """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
    return {
        "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
        "HOME": "/root",
        "LANG": "C.UTF-8",
        "LC_ALL": "C.UTF-8",
        "PKG_NAME": pkg.name,
        "PKG_VERSION": pkg.version,
        "PKG_ACTION": hook_name,
        "PKG_HOOK_API": HOOK_API_VERSION,
    }


def _hook_timeout() -> int:
    try:
        return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
    except Exception:
        return 60


def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
    """Uruchamia skrypt hooka jeśli istnieje.

    Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
    pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
    ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
    """
    hook_path = os.path.join(hooks_dir, hook_name)
    if not os.path.exists(hook_path):
        return False

    if os.environ.get("PAG_NO_HOOKS", "") == "1":
        print(f"  ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
        _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
        return False

    os.chmod(hook_path, 0o755)
    env = _hook_env(pkg, hook_name)
    tag = f"{hook_name} {pkg.name}-{pkg.version}"
    try:
        result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
                                check=False, capture_output=True, text=True,
                                cwd="/")
        _HOOKS_RUN.append(tag)
        if result.returncode != 0:
            print(f"  ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
            if result.stderr:
                print(f"      {result.stderr.strip()[-200:]}")
            _audit(f"hook FAIL {tag} rc={result.returncode}")
        else:
            _audit(f"hook OK {tag}")
        return True
    except subprocess.TimeoutExpired:
        print(f"  ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
        _audit(f"hook TIMEOUT {tag}")
        return False
    except Exception as e:
        print(f"  ⚠ Hook {hook_name} dla {pkg.name}: {e}")
        _audit(f"hook ERROR {tag}: {e}")
        return False

# =============================================================================
# TRANSAKCJE I ROLLBACK
# =============================================================================

def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
                        upgrade_backups=None, upgrade_backup_root=""):
    history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
    # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
    # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
    executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
    _HOOKS_RUN.clear()
    entry = {
        "action": action, "packages": packages, "success": success,
        "timestamp": datetime.now().isoformat(),
        "snapshot": snapshot,
        "file_journal": file_journal,  # lista plików do wycofania
        "hooks": executed_hooks,       # wykonane hooki (pre/post-install/remove)
    }
    if upgrade_backups:
        entry["upgrade_backups"] = upgrade_backups     # {dst: backup_path}
        entry["upgrade_backup_root"] = upgrade_backup_root
    history.append(entry)
    if len(history) > 50:
        history = history[-50:]
    save_json(HISTORY_FILE, history)

def cmd_history():
    if not os.path.exists(HISTORY_FILE):
        print(_("no_history")); return
    history = load_json(HISTORY_FILE)
    if not history:
        print(_("no_history")); return
    print(f"Ostatnie transakcje ({len(history)}):")
    for i, e in enumerate(reversed(history), 1):
        icon = "✅" if e["success"] else "❌"
        pkgs = ", ".join(e["packages"][:5])
        if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
        print(f"  {i}. {icon} {e['action']}: {pkgs}")
        print(f"     {e['timestamp']}")

def cmd_rollback():
    if not os.path.exists(HISTORY_FILE):
        print(_("no_history")); return 1
    history = load_json(HISTORY_FILE)
    if not history:
        print(_("no_history")); return 1

    last = None
    for e in reversed(history):
        if e["success"] and e.get("snapshot"):
            last = e; break

    if not last:
        print("❌ No snapshot to restore."); return 1

    print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
    print(f"   Packages: {', '.join(last['packages'][:10])}")

    if os.environ.get("PAG_YES", "") == "1":
        print(_("continue_q") + " t (--yes)")
    else:
        ans = input(_("continue_q")).strip().lower()
        if ans and ans not in ("t","y"):
            return 0

    # Przywróć installed.json
    save_json(INSTALLED_DB, last["snapshot"])

    # Wycofaj fizyczne pliki (jeśli zapisano journal)
    file_journal = last.get("file_journal", [])
    upgrade_backups = last.get("upgrade_backups", {}) or {}
    backup_root = last.get("upgrade_backup_root", "")

    # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
    for dst, bpath in upgrade_backups.items():
        full = os.path.join(PAG_ROOT, dst.lstrip("/"))
        if bpath and os.path.lexists(bpath):
            try:
                os.makedirs(os.path.dirname(full), exist_ok=True)
                os.replace(bpath, full)
            except OSError:
                pass

    # Usuń nowe pliki (które nie miały poprzedniej wersji)
    backed = set(upgrade_backups)
    if file_journal:
        for fpath in reversed(file_journal):
            if fpath in backed:
                continue
            full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
            if os.path.exists(full) or os.path.islink(full):
                os.remove(full)
        print(f"   {_('rollback_files', len(file_journal))}")

    # Sprzątanie pustych katalogów + katalogu backupów
    dirs = set()
    for fpath in file_journal:
        parent = os.path.dirname(fpath)
        while parent and parent != "/":
            dirs.add(parent)
            parent = os.path.dirname(parent)
    for d in sorted(dirs, key=len, reverse=True):
        full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
        if os.path.isdir(full_d):
            try:
                os.rmdir(full_d)
            except OSError:
                pass
    if backup_root:
        shutil.rmtree(backup_root, ignore_errors=True)

    print(f"✅ {_('rollback_restored')}")
    _record_transaction("rollback", last["packages"], True, None)
    return 0

# =============================================================================
# INSTALACJA
# =============================================================================

def _install_local_pkg_files(paths, install_succeeded):
    """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
    Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
    Zwraca (failed_count, installed_files)."""
    failed = 0
    all_files = []
    for p in paths:
        p = os.path.abspath(p)
        if not os.path.isfile(p):
            print(f"  ❌ Nie znaleziono pakietu: {p}")
            failed += 1
            continue
        try:
            with tarfile.open(p, "r:xz") as tf:
                meta = tf.extractfile("metadata.json")
                if meta is None:
                    print(f"  ❌ {p}: brak metadata.json")
                    failed += 1
                    continue
                data = json.loads(meta.read())
        except Exception as e:
            print(f"  ❌ {p}: nie udało się odczytać pakietu ({e})")
            failed += 1
            continue
        pkg = PackageInfo(data, repo="local")
        print(f"  ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
        ok, files, _ = _atomic_install(p, pkg)
        if ok:
            install_succeeded(pkg, files)
            all_files.extend(f["path"] for f in files)
            print("✅")
        else:
            print("❌")
            failed += 1
    return failed, all_files


def cmd_install(package_names, as_dep=False, upgrade=False):
    ensure_dirs()
    installed_db = load_json(INSTALLED_DB)
    world = load_world()
    pinned = load_json(PINNED_FILE)

    # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
    # nie wymaga repozytorium ani GPG.
    local_files = [p for p in package_names if p.endswith(PKG_EXT) or
                   (os.sep in p and os.path.isfile(os.path.abspath(p)))]
    if local_files:
        def _ok(pkg, files):
            installed_db[pkg.name] = {
                "version": pkg.version, "description": pkg.description,
                "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
                "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
                "repo": "local",
            }
            world.add(pkg.name)
        failed_local, _fl = _install_local_pkg_files(local_files, _ok)
        save_json(INSTALLED_DB, installed_db)
        save_world(world)
        if failed_local:
            return 1
        _refresh_dynamic_linker_cache()
        package_names = [n for n in package_names if n not in
                         [os.path.abspath(x) for x in local_files] and
                         n not in local_files]
        to_install = []
        if not package_names:
            return 0
        # pozostałe argumenty to nazwy pakietów z repo – kontynuuj

    repo_pkgs = fetch_all_packages()

    if not repo_pkgs:
        print(f"❌ {_('no_index')}"); return 1

    for name in list(package_names):
        if name in pinned:
            print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
            package_names.remove(name)

    to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)

    # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
    # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
    if upgrade:
        upgrade_targets = [
            name for name in package_names
            if name in repo_pkgs
            and name in installed_db
            and _version_newer(
                repo_pkgs[name].version,
                installed_db[name].get("version", "0")
            )
            and name not in pinned
        ]
        for name in upgrade_targets:
            if name not in to_install:
                to_install.append(name)

    if not to_install and not missing_deps:
        print(f"✅ {_('all_installed')}"); return 0

    # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
    fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)

    if fatal_missing > 0:
        print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
        print(f"   Zainstaluj brakujące pakiety lub dodaj repozytoria.")
        return 1

    if not to_install:
        print(f"✅ {_('all_installed')}"); return 0

    MAX_MB = MAX_PKG_SIZE // 1048576
    for n in to_install:
        if not _validate_pkg_name(n):
            print(f"  {_("sec_badname", name=n)}")
            return 1
        sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
        if sz > MAX_PKG_SIZE:
            mb = sz // 1048576
            print(f"  {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
            return 1
    total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
    print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
    for name in to_install:
        p = repo_pkgs.get(name)
        if p:
            if name in installed_db:
                marker = " [upgrade]" if upgrade else ""
            else:
                marker = f" [{_('new')}]"
            print(f"   {name}-{p.version}{marker}")

    if not as_dep and not upgrade:
        if os.environ.get("PAG_YES", "") == "1":
            print(_("continue_q") + " t (--yes)")
        else:
            ans = input(_("continue_q")).strip().lower()
            if ans and ans not in ("t","y"):
                print(_("cancelled")); return 0

    snapshot = json.loads(json.dumps(installed_db))
    all_installed_files = []
    failed = []
    # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
    stale_candidates = []
    # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
    backup_root = ""
    all_backups: List[Tuple[str, str]] = []  # (backup_path, dst)
    if upgrade and to_install:
        backup_root = _new_upgrade_backup_root()

    # --- Dziennik transakcji (dla pełnej atomowości) ---
    # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
    # w tej transakcji przez _rollback_transaction().
    transaction_journal: List[Tuple[str, str, str]] = []  # (op, src, dst)

    # --- Tryb immutable: utwórz nowy deployment ---
    immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
    deploy_dir = ""
    deploy_id = ""
    if immutable:
        print(f"\n  🏗️  Tworzenie nowego deploymentu...")
        deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
        target_root = deploy_dir
    else:
        target_root = ""

    # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
    to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
    if len(to_download) > 1:
        print(f"\n  ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
        downloaded = _download_packages_parallel(to_download)
    else:
        downloaded = {}

    # --- Faza 2: Instalacja z paskiem postępu ---
    t0 = time.time()

    for name in to_install:
        pkg = repo_pkgs.get(name)
        if not pkg:
            print(f"  ❌ {name}: {_('not_found')}")
            failed.append(name)
            break

        # Pasek postępu na stderr (nie koliduje z download barem)
        idx = len(all_installed_files) + 1
        pct = (idx - 1) / len(to_install) * 100
        fl = int(25 * pct / 100)
        pbar = "█" * fl + "░" * (25 - fl)
        elapsed = time.time() - t0
        if idx > 1 and elapsed > 0:
            avg = elapsed / (idx - 1)
            remaining = avg * (len(to_install) - idx + 1)
            if remaining < 60:
                eta_s = f" ~{remaining:.0f}s"
            else:
                eta_s = f" ~{remaining/60:.1f}m"
        else:
            eta_s = ""
        status = f"  [{pbar}] {idx}/{len(to_install)} ({pct:.0f}%){eta_s}"
        print(status, file=sys.stderr, flush=True)

        print(f"  ↓ {name}-{pkg.version} ...", end=" ", flush=True)

        # Pobierz (z cache fazy 1 lub bezpośrednio)
        pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
        if not pkg_path:
            print(f"❌ {_('download_fail')}")
            failed.append(name)
            break  # przerwij transakcję

        # GPG
        gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
        if not gpg_ok:
            print(f"❌ {_('gpg_fail')}: {gpg_msg[:60]}")
            failed.append(name)
            break  # PRZERWIJ – niezaufany pakiet

        # SHA256 całego pakietu
        if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
            print(f"❌ {_('sha256_mismatch')}")
            failed.append(name)
            break  # PRZERWIJ – uszkodzony pakiet

        # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
        old_files = _db_get_package_files(name) if name in installed_db else []

        # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
        ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
                                              backup_dir=backup_root)
        if ok:
            installed_db[name] = {
                "version": pkg.version, "description": pkg.description,
                "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
                "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
                "repo": pkg.repo_url,
            }
            if not as_dep and name in package_names:
                world.add(name)
            print("✅")
            all_installed_files.extend(f["path"] for f in files)
            all_backups.extend(backup_j)

            # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
            # których nie ma już w nowej wersji.
            if upgrade and old_files:
                stale_candidates.append((name, old_files, [f["path"] for f in files]))

            # Po instalacji kernela – przebuduj initramfs
            if _is_kernel_package(name):
                _rebuild_initramfs(deploy_dir)
        else:
            print("❌")
            failed.append(name)
            break  # PRZERWIJ – błąd instalacji

    # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
    if failed:
        print(f"\n  ↩ Cofanie transakcji ({len(failed)} błędów)...")
        _rollback_transaction(installed_db, snapshot, all_installed_files,
                              deploy_dir, immutable, backups=all_backups)
        if backup_root:
            shutil.rmtree(backup_root, ignore_errors=True)
        _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
        return 1

    # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
    # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
    for pkg_name, old_files, new_paths in stale_candidates:
        _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
                            backup_root, all_backups)

    save_json(INSTALLED_DB, installed_db)
    save_world(world)
    _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
                         file_journal=all_installed_files,
                         upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
                         upgrade_backup_root=backup_root)

    # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
    if backup_root:
        _purge_old_backups(keep_root=backup_root)

    # --- Tryb immutable: przełącz na nowy deployment ---
    if immutable and not failed:
        _refresh_dynamic_linker_cache(deploy_dir)
        print(f"\n  🔄 Przełączanie na deployment {deploy_id}...")
        _switch_deployment(deploy_dir)
        print(f"  ✅ Aktywny deployment: {deploy_id}")
        _update_grub_config()
        cmd_deploy_cleanup(keep=5)  # Zostawia 5 najnowszych deploymentów
        print(f"  💡 Restart wymagany do przeładowania systemu.")
    else:
        _refresh_dynamic_linker_cache()

    print(f"\n✅ {_('installed', len(to_install))}")
    return 0


def _rollback_transaction(installed_db: dict, snapshot: dict,
                           installed_files: List[str],
                           deploy_dir: str, is_immutable: bool,
                           backups: Optional[List[Tuple[str, str]]] = None):
    """
    Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
    Przywraca installed_db do stanu sprzed transakcji.
    Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
    Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
    """
    # Przywróć installed_db
    installed_db.clear()
    installed_db.update(snapshot)

    root = deploy_dir if is_immutable else PAG_ROOT
    backup_map = {dst: src for src, dst in (backups or [])}

    # Przywróć stare wersje z backupów (upgrade)
    for dst, bpath in backup_map.items():
        full = os.path.join(root, dst.lstrip("/"))
        if os.path.lexists(bpath):
            try:
                os.makedirs(os.path.dirname(full), exist_ok=True)
                os.replace(bpath, full)
            except OSError:
                pass

    # Usuń nowe pliki (które nie miały poprzedniej wersji)
    for fpath in reversed(installed_files):
        if fpath in backup_map:
            continue
        full = os.path.join(root, fpath.lstrip("/"))
        if os.path.isfile(full) or os.path.islink(full):
            try:
                os.remove(full)
            except OSError:
                pass

    # Wyczyść puste katalogi
    dirs_to_check = set()
    for fpath in installed_files:
        parent = os.path.dirname(fpath)
        while parent and parent != "/":
            dirs_to_check.add(parent)
            parent = os.path.dirname(parent)
    for d in sorted(dirs_to_check, key=len, reverse=True):
        full_d = os.path.join(root, d.lstrip("/"))
        if os.path.isdir(full_d):
            try:
                os.rmdir(full_d)
            except OSError:
                pass

    # W trybie immutable: usuń nieudany deployment
    if is_immutable and deploy_dir:
        shutil.rmtree(deploy_dir, ignore_errors=True)

    save_json(INSTALLED_DB, snapshot)


# =============================================================================
# USUWANIE
# =============================================================================

def cmd_remove(package_names):
    installed_db = load_json(INSTALLED_DB)
    world = load_world()
    snapshot = json.loads(json.dumps(installed_db))
    removed = []

    total = len(package_names)
    for i, name in enumerate(package_names, 1):
        if name not in installed_db:
            print(f"  ⚠ {name}: not installed"); continue

        # Pasek postępu
        pct = (i - 1) / total * 100
        filled = int(25 * pct / 100)
        print(f"  🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%)  ", end="\r", file=sys.stderr, flush=True)

        print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)

        # Pre-remove hook (jeśli dostępny w staging)
        _run_hook_for_installed(name, "pre-remove")

        count, _ = _safe_remove_files(name, installed_db)
        del installed_db[name]
        world.discard(name)
        removed.append(name)
        print(f"✅ ({count} files)")

        # Post-remove hook + sprzątanie zapisanych hooków
        _run_hook_for_installed(name, "post-remove")
        shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)

    save_json(INSTALLED_DB, installed_db)
    save_world(world)
    _record_transaction("remove", removed, True, snapshot)

    print(file=sys.stderr)  # wyczyść linię paska postępu

    if not removed: return 0
    print(f"\n✅ Removed {len(removed)}.")

    orphans = _find_orphans(installed_db, world)
    if orphans:
        print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
        print("   'pag remove-orphans' to clean up.")
    return 0

def _run_hook_for_installed(pkg_name, hook_name):
    """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
    hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
    if os.path.isdir(hook_dir):
        ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
        _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))

# =============================================================================
# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
# =============================================================================

def _cleanup_tmp_files(*paths):
    """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
    for p in paths:
        try:
            if os.path.isfile(p):
                os.remove(p)
        except OSError:
            pass


def cmd_self_update():
    """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).

    Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
    kontrola składni (compile) → backup → atomowe os.replace. Nowa wersja
    idzie do tego samego katalogu (/usr/local/bin/.pag.new), dzięki czemu
    podmiana jest atomowa; jeśli system padnie w trakcie, stary pag zostaje.
    """
    repos = get_repos()
    if not repos:
        print("❌ Brak repozytoriów w konfiguracji.")
        return 1
    base = repos[0]
    dst = "/usr/local/bin/pag"
    dst_new = dst + ".new"
    dst_bak = dst + ".bak"
    print(f"🔄 Sprawdzam aktualizację pag z {base}...")
    try:
        with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
            data = r.read()
        with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
            sig = r.read()
    except Exception as e:
        print(f"  ❌ Nie można pobrać pag: {e}")
        return 1

    # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
    with open(dst_new, "wb") as f:
        f.write(data)
    with open(dst_new + ".asc", "wb") as f:
        f.write(sig)

    # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
    insecure = os.environ.get("PAG_INSECURE", "") == "1"
    ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
    if not ok:
        # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
        res = _gpg_run("--verify", dst_new + ".asc", dst_new,
                       capture_output=True, text=True)
        _stderr = (res.stderr or "")
        if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
            try:
                with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
                    keydata = r.read()
                with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
                    tmp.write(keydata); tmp.flush()
                    _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
                    os.unlink(tmp.name)
                print(f"  🔑 Importowano klucz repo z {base}/paganos.asc")
                ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
            except Exception:
                pass
        if not ok:
            if insecure:
                print("  ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
            else:
                print("  ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
                _cleanup_tmp_files(dst_new, dst_new + ".asc")
                return 1
    # Sprawdź fingerprint względem przypiętego klucza repo
    pinned = _repo_pinned_fp(base)
    if pinned:
        if not fp:
            print("  ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
            _cleanup_tmp_files(dst_new, dst_new + ".asc")
            return 1
        if fp != pinned.upper():
            if insecure:
                print("  ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
            else:
                print("  ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
                print(f"     Oczekiwany: {pinned}, Otrzymany: {fp}")
                _cleanup_tmp_files(dst_new, dst_new + ".asc")
                return 1

    # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
    try:
        with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
            sha = r.read().decode().strip().split()[0]
        if sha:
            actual = hashlib.sha256(data).hexdigest()
            if actual.lower() != sha.lower():
                print(f"  ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
                _cleanup_tmp_files(dst_new, dst_new + ".asc")
                return 1
            print("  ✅ SHA256 zgodny")
    except Exception:
        # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
        pass

    # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
    try:
        compile(data, "pag", "exec")
    except SyntaxError as e:
        print(f"  ❌ Błąd składni w nowym pag: {e}")
        _cleanup_tmp_files(dst_new, dst_new + ".asc")
        return 1

    m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+)"', data[:3000])
         or re.search(rb"v(\d+\.\d+\.\d+)", data[:3000]))
    new_ver = m.group(1).decode() if m else "?"
    print(f"  ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")

    # --- 4. Backup + atomowa podmiana ---
    if os.path.exists(dst):
        shutil.copy2(dst, dst_bak)
    os.chmod(dst_new, 0o755)
    os.replace(dst_new, dst)  # atomowe na tym samym FS
    try:
        if os.path.exists(dst_new + ".asc"):
            os.remove(dst_new + ".asc")
    except OSError:
        pass
    print(f"  ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
    print("   Uruchom ponownie pag, aby użyć nowej wersji.")
    return 0


def cmd_update():
    force = "--force" in sys.argv
    print(f"🔄 {'Forced refresh' if force else 'Updating'} indexes...")
    for repo_url in get_repos():
        pkgs = fetch_repo_index(repo_url, force=force)
        cp = _repo_cache_path(repo_url)
        has_sig = os.path.exists(cp + ".sig")
        print(f"  {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
    total = 0
    for r in get_repos():
        cp = _repo_cache_path(r)
        if os.path.exists(cp):
            try:
                total += len(json.load(open(cp)).get("packages", []))
            except Exception:
                pass
    print(f"✅ {_('updated_done', total)}")

    # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
    try:
        for r in get_repos():
            cp = _repo_cache_path(r)
            if os.path.exists(cp):
                d = json.load(open(cp))
                rv = d.get("pag_version", "")
                if rv and rv != PAG_VERSION:
                    print(f"  ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
    except Exception:
        pass

def cmd_upgrade():
    ensure_dirs()
    installed = load_json(INSTALLED_DB)
    pinned = load_json(PINNED_FILE)
    repo = fetch_all_packages()
    if not repo:
        print(f"❌ {_('no_index')}")
        return 1
    upgrades = [n for n, i in installed.items()
                if n not in pinned and (rp := repo.get(n)) and _version_newer(rp.version, i["version"])]
    if not upgrades:
        print(f"✅ {_('all_up_to_date')}"); return 0
    print(f"📦 {_('upgrading', len(upgrades))}")
    for n in upgrades:
        print(f"   {n}: {installed[n]['version']} → {repo[n].version}")
    if os.environ.get("PAG_YES", "") == "1":
        print(_("continue_q") + " t (--yes)")
    else:
        ans = input(_("continue_q")).strip().lower()
        if ans and ans not in ("t","y"): return 0
    return cmd_install(upgrades, upgrade=True)

def cmd_list(installed_only=False):
    if installed_only:
        db = load_json(INSTALLED_DB)
        pinned = load_json(PINNED_FILE)
        if not db: print("No packages installed."); return
        print(f"Installed ({len(db)}):")
        for n, i in sorted(db.items()):
            pin = " 📌" if n in pinned else ""
            print(f"  {n}-{i['version']}{pin}  – {i.get('description','')}")
    else:
        pkgs = fetch_all_packages()
        installed = load_json(INSTALLED_DB)
        pinned = load_json(PINNED_FILE)
        print(f"Available ({len(pkgs)}):")
        for n, p in sorted(pkgs.items()):
            m = "✓" if n in installed else " "
            extra = f"  [installed: {installed[n]['version']}]" if n in installed else ""
            if n in pinned: extra += " 📌"
            print(f"  [{m}] {n}-{p.version}  – {p.description}{extra}")

def cmd_search(query):
    pkgs = fetch_all_packages()
    results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
    if not results: print(f"❌ No results for: {query}"); return
    installed = load_json(INSTALLED_DB)
    print(f"Results for '{query}' ({len(results)}):")
    for n,p in sorted(results):
        print(f"  [{'✓' if n in installed else ' '}] {n}-{p.version}")
        print(f"      {p.description}")


def _smart_search(query: str) -> int:
    """
    Inteligentne wyszukiwanie: repo PaganOS + Flathub.
    Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
    Pokazuje dostępne źródła i sugeruje komendy instalacji.
    """
    # 1. Repo PaganOS
    try:
        pkgs = fetch_all_packages()
    except Exception:
        pkgs = {}
    repo_lower = [(n, p) for n, p in pkgs.items()
                  if query.lower() in n.lower() or query.lower() in p.description.lower()]

    # 2. Flathub (jeśli dostępny)
    flat = _flatpak_search_raw(query) if _check_flatpak() else []

    if not repo_lower and not flat:
        print(f"\n  ❌ '{query}' — nie znaleziono.")
        print(f"     Repo PaganOS: pag search {query}")
        if _check_flatpak():
            print(f"     Flathub:      pag flatpak search {query}")
        print(f"     Dodaj repo:   pag repo-add <url>")
        return 1

    installed = load_json(INSTALLED_DB)

    # ── Repo PaganOS ──
    if repo_lower:
        exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
        show = (exact or repo_lower)[:6]
        print(f"\n  📦 PaganOS — '{query}':")
        for n, p in sorted(show):
            mark = "✓" if n in installed else " "
            desc = p.description[:70] if len(p.description) > 75 else p.description
            print(f"  [{mark}] {n}-{p.version}")
            if desc:
                print(f"      {desc}")
        if len(repo_lower) > 6:
            print(f"      ... i {len(repo_lower) - 6} więcej (pag search {query})")

    # ── Flathub ──
    if flat:
        print(f"\n  📦 Flathub — '{query}':")
        for r in flat[:5]:
            mark = "✓" if r.get("installed") else " "
            name = r.get("name") or r.get("application", "?")
            desc = (r.get("description") or "")[:65]
            print(f"  [{mark}] {name}")
            if desc:
                print(f"      {desc}")
        if len(flat) > 5:
            print(f"      ... i {len(flat) - 5} więcej (pag flatpak search {query})")

    # ── Sugestie instalacji ──
    print()
    if repo_lower:
        best = sorted(repo_lower, key=lambda x: (x[0].lower() != query.lower(), -len(x[1].name if hasattr(x[1], 'name') else 0)))[0][0]
        if best in installed:
            print(f"  ✓ {best} jest już zainstalowany ({installed[best]['version']})")
        else:
            print(f"  💡 sudo pag install {best}")
    if flat:
        best_fp = flat[0].get("application") or flat[0].get("name", query)
        print(f"  💡 pag flatpak install {best_fp}")

    return 0

def cmd_info(name):
    pkgs = fetch_all_packages()
    p = pkgs.get(name)
    info = load_json(INSTALLED_DB).get(name)
    if not p and not info: print(f"❌ '{name}' not found."); return 1
    print(f"📦 {name}")
    if p:
        print(f"   Version (repo):  {p.version}")
        print(f"   Description:     {p.description}")
        print(f"   Size:            {p.size_bytes/1048576:.1f} MB")
        print(f"   SHA256:          {p.sha256[:32]}...")
        print(f"   GPG:             {p.gpg_fp or 'none'}")
        print(f"   Dependencies:    {', '.join(p.dependencies) if p.dependencies else '(none)'}")
    if info:
        print(f"   Installed:       {info['version']} ({info.get('installed_at','?')})")

def cmd_files(name):
    if name not in load_json(INSTALLED_DB):
        print(f"❌ '{name}' not installed."); return 1
    files = _db_get_package_files(name)
    print(f"Files in {name} ({len(files)}):")
    for f in sorted(files): print(f"  {f}")

def cmd_verify(deep=False):
    installed = load_json(INSTALLED_DB)
    if not installed: print("Nothing to verify."); return
    errors = []

    for name in installed:
        for fpath in _db_get_package_files(name):
            full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
            if not (os.path.exists(full) or os.path.islink(full)):
                errors.append(f"  ❌ {name}: missing {fpath}")
            elif deep:
                checksums = _db_get_all_file_checksums()
                expected = checksums.get(fpath, "")
                if expected:
                    actual = _sha256_file(full)
                    if actual != expected:
                        errors.append(f"  ❌ {name}: SHA256 mismatch {fpath}")

    if errors:
        print(f"❌ {_('verify_errors', len(errors))}")
        for e in errors[:50]: print(e)
        return 1
    total = _db_count_files()
    print(f"✅ {_('verify_ok', total)}")

# =============================================================================
# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
# =============================================================================

def cmd_pin(name, version=""):
    pinned = load_json(PINNED_FILE)
    if version:
        pinned[name] = version
    else:
        info = load_json(INSTALLED_DB).get(name, {})
        pinned[name] = info.get("version", "?")
    save_json(PINNED_FILE, pinned)
    print(f"📌 {name} {_('pinned_to')} {pinned[name]}")

def cmd_unpin(name):
    pinned = load_json(PINNED_FILE)
    if name in pinned:
        del pinned[name]; save_json(PINNED_FILE, pinned)
        print(f"🔓 {name} {_('unpinned')}")
    else:
        print(f"⚠ {name} {_('not_pinned')}")

def cmd_pinned():
    pinned = load_json(PINNED_FILE)
    if not pinned: print(_("no_pinned")); return
    print(_("pinned_list", len(pinned)))
    for n,v in sorted(pinned.items()): print(f"  📌 {n} = {v}")

def cmd_clean():
    if os.path.isdir(PAG_CACHE):
        count = size = 0
        for f in os.listdir(PAG_CACHE):
            fp = os.path.join(PAG_CACHE, f)
            if os.path.isfile(fp):
                size += os.path.getsize(fp); os.remove(fp); count += 1
        print(f"✅ {_('cache_cleared', count, size/1048576)}")

def cmd_remove_orphans():
    installed = load_json(INSTALLED_DB)
    world = load_world()
    orphans = _find_orphans(installed, world)
    if not orphans: print("✅ No orphans."); return
    print(f"Orphans ({len(orphans)}):")
    for n in sorted(orphans): print(f"  {n}-{installed[n]['version']}")
    if os.environ.get("PAG_YES", "") == "1":
        print(_("continue_q") + " t (--yes)")
    else:
        ans = input(_("continue_q")).strip().lower()
        if ans and ans not in ("t","y"): return
    cmd_remove(list(orphans))


# =============================================================================
# PROVIDES – PAKIETY WIRTUALNE
# =============================================================================

PROVIDES_MAP = {
    "pkgconfig(glib-2.0)": "glib",
    "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
    "pkgconfig(gtk+-3.0)": "gtk",
    "pkgconfig(gtk4)": "gtk",
    "pkgconfig(zlib)": "zlib",
    "pkgconfig(libffi)": "libffi",
    "pkgconfig(expat)": "expat",
    "pkgconfig(libsystemd)": "systemd",
    "pkgconfig(dbus-1)": "dbus",
    "pkgconfig(mount)": "util-linux",
    "pkgconfig(blkid)": "util-linux",
    "pkgconfig(libcap)": "libcap",
    "pkgconfig(liblzma)": "xz",
    "pkgconfig(libzstd)": "zstd",
    "pkgconfig(bzip2)": "bzip2",
    "pkgconfig(libcurl)": "curl",
    "pkgconfig(openssl)": "openssl",
    "pkgconfig(libpcre2-8)": "pcre2",
    "pkgconfig(libxml-2.0)": "libxml2",
    "pkgconfig(libxslt)": "libxslt",
    "pkgconfig(freetype2)": "freetype",
    "pkgconfig(fontconfig)": "fontconfig",
    "pkgconfig(harfbuzz)": "harfbuzz",
    "pkgconfig(cairo)": "cairo",
    "pkgconfig(pango)": "pango",
}

def _resolve_provides(name: str, repo: dict) -> str:
    """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy z repo."""
    if name in repo:
        return name
    if name in PROVIDES_MAP:
        real = PROVIDES_MAP[name]
        if real in repo:
            return real
    # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
    for _pkg_name, _pkg in repo.items():
        _provs = getattr(_pkg, "provides", None) or []
        if name in _provs:
            return _pkg_name
    clean = name
    if name.startswith("pkgconfig(") and ")" in name:
        clean = name.split("(", 1)[1].rstrip(")")
    elif name.startswith("pkgconfig32(") and ")" in name:
        clean = name.split("(", 1)[1].rstrip(")")
    if clean != name and clean in repo:
        return clean
    return name


def cmd_why(pkg_name: str):
    """Pokazuje dlaczego pakiet jest zainstalowany."""
    installed = load_json(INSTALLED_DB)
    world = load_world()
    if pkg_name not in installed:
        print(f"  {pkg_name}: {_('why_not_installed')}"); return 1
    if pkg_name in world:
        print(f"  {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
        return 0
    parents = set()
    for w in world:
        _find_dep_path(w, pkg_name, installed, set(), [], parents)
    if parents:
        for pp in sorted(parents):
            print(f"  {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
    else:
        print(f"  {pkg_name}: {_('why_dependency')} (unknown/orphan)")
    return 0


def _find_dep_path(cur, target, installed, visited, path, results):
    if cur in visited: return
    visited.add(cur); path.append(cur)
    if cur == target:
        results.add(tuple(path))
    else:
        for dep in installed.get(cur, {}).get("dependencies", []):
            _find_dep_path(dep, target, installed, visited, path, results)
    path.pop(); visited.discard(cur)


def cmd_autoremove():
    """Automatycznie usuwa osierocone zależności bez pytania."""
    installed = load_json(INSTALLED_DB)
    world = load_world()
    orphans = _find_orphans(installed, world)
    if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
    print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
    return cmd_remove(list(orphans))


def cmd_download(package_names):
    """Pobiera pakiety do cache bez instalowania."""
    ensure_dirs()
    repo = fetch_all_packages()
    if not repo: print(f"❌ {_('no_index')}"); return 1
    total_size = 0; downloaded = []
    for name in package_names:
        pkg = repo.get(name)
        if not pkg:
            print(f"  ❌ {name}: {_('not_found')}"); continue
        print(f"  ↓ {name}-{pkg.version} ...", end=" ", flush=True)
        path = _download_pkg(pkg)
        if path:
            total_size += os.path.getsize(path)
            downloaded.append(name)
            print(_c("green", "✓"))
        else:
            print(_c("red", "✗"))
    if downloaded:
        print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
    return 0 if len(downloaded) == len(package_names) else 1


def cmd_stats():
    """Wyświetla statystyki PAG."""
    installed = load_json(INSTALLED_DB)
    history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
    total_size = sum(i.get("size_bytes", 0) for i in installed.values())
    total_files = _db_count_files()
    cache_size = sum(
        os.path.getsize(os.path.join(PAG_CACHE, f))
        for f in os.listdir(PAG_CACHE)
        if os.path.isfile(os.path.join(PAG_CACHE, f))
    ) if os.path.isdir(PAG_CACHE) else 0
    last_update = "never"
    for e in reversed(history):
        if e.get("action") in ("install", "upgrade") and e.get("success"):
            last_update = e.get("timestamp", "?")[:19]; break
    print(f"\n  {_c('bold', _('stats_title'))}")
    print(f"  {'─' * 40}")
    print(f"  {_('stats_packages'):<30} {len(installed)}")
    print(f"  {_('stats_files'):<30} {total_files}")
    print(f"  {_('stats_size'):<30} {total_size/1048576:.1f} MB")
    print(f"  {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
    print(f"  {_('stats_history'):<30} {len(history)}")
    print(f"  {_('stats_last_update'):<30} {last_update}")
    by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
    if by_size:
        print(f"\n  {_c('dim', 'Top 5:')}")
        for n, i in by_size:
            print(f"    {n}-{i['version']}  {i.get('size_bytes',0)/1048576:.1f} MB")
    return 0


def cmd_repo_add(url, name=None):
    if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
        print(f"  {_('sec_https')}"); return 1
    ensure_dirs()
    url = url.rstrip("/")
    repos = get_repos()
    if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
    if name:
        # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
        os.makedirs(REPOS_DIR, exist_ok=True)
        target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
        with open(target, "w") as f: f.write(f"{url}\n")
        print(f"✅ {_('repo_added', url)} → {target}")
        return
    with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
    print(f"✅ {_('repo_added', url)}")

def cmd_repo_list():
    for i, url in enumerate(get_repos(), 1): print(f"  {i}. {url}")

def _check_flatpak():
    if not shutil.which("flatpak"):
        print(f"❌ {_('flatpak_missing')}"); return False
    r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
    if "flathub" not in r.stdout:
        print(f"⚠ {_('flatpak_adding')}")
        subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
                         "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
    return True

def _spinner(msg: str):
    """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
    stop = threading.Event()
    def _spin():
        for c in itertools.cycle("|/-\\"):
            if stop.is_set():
                break
            sys.stdout.write(f"\r  {msg} {c}")
            sys.stdout.flush()
            time.sleep(0.1)
    t = threading.Thread(target=_spin, daemon=True)
    t.start()
    def _stop():
        stop.set()
        t.join(timeout=0.3)
        sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
        sys.stdout.flush()
    return _stop


def _flatpak_search_raw(query: str) -> List[dict]:
    """Szuka we Flathub i zwraca listę wyników jako słowniki."""
    if not _check_flatpak():
        return []
    stop = _spinner("Szukam we Flathub...")
    try:
        try:
            r = subprocess.run(
                ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
                capture_output=True, text=True, timeout=120
            )
        finally:
            stop()
        if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
            print(f"  ⚠ flatpak search: {r.stderr.strip()[:150]}")
        results = []
        for line in r.stdout.strip().split("\n"):
            parts = line.split("\t")
            if len(parts) >= 3:
                results.append({
                    "name": parts[0].strip(),
                    "description": parts[1].strip() if len(parts) > 1 else "",
                    "app_id": parts[2].strip() if len(parts) > 2 else "",
                    "version": parts[3].strip() if len(parts) > 3 else "",
                    "branch": parts[4].strip() if len(parts) > 4 else "stable",
                    "origin": parts[5].strip() if len(parts) > 5 else "flathub",
                })
        return results
    except Exception as e:
        print(f"  ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
        return []

def _flatpak_find_best(query: str) -> Optional[dict]:
    """
    Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
    - Jeśli query dokładnie pasuje do app_id → zwraca od razu
    - Jeśli query pasuje do nazwy → zwraca pierwsze
    - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
    - Jeśli brak → zwraca None
    """
    results = _flatpak_search_raw(query)
    if not results:
        return None

    # Dokładne dopasowanie app_id
    exact = [r for r in results if r["app_id"].lower() == query.lower()]
    if exact:
        return exact[0]

    # Dokładne dopasowanie nazwy
    exact_name = [r for r in results if r["name"].lower() == query.lower()]
    if exact_name:
        return exact_name[0]

    # Jednoznaczne dopasowanie (tylko 1 wynik)
    if len(results) == 1:
        return results[0]

    # Wiele wyników – pokaż użytkownikowi
    print(f"\n  {_('flatpak_found', len(results))}")
    for i, r in enumerate(results):
        print(f"  {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
        if r["version"]:
            print(f"     {_('flatpak_info_version')}: {r['version']}")
        if r["description"]:
            desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
            print(f"     {desc}")

    try:
        choice = input(f"\n  Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
        if not choice:
            return None
        idx = int(choice) - 1
        if 0 <= idx < len(results):
            return results[idx]
    except (ValueError, IndexError):
        pass
    return None

def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
    """Zwraca info o zainstalowanym flatpaku lub None."""
    try:
        r = subprocess.run(
            ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
            capture_output=True, text=True, timeout=10
        )
        if r.returncode != 0:
            return None
        parts = r.stdout.strip().split("\t")
        if len(parts) < 3:
            return None
        return {
            "name": parts[0].strip(),
            "version": parts[1].strip() if len(parts) > 1 else "",
            "branch": parts[2].strip() if len(parts) > 2 else "",
            "origin": parts[3].strip() if len(parts) > 3 else "",
            "size": parts[4].strip() if len(parts) > 4 else "",
            "description": parts[5].strip() if len(parts) > 5 else "",
        }
    except Exception:
        return None

def _flatpak_is_installed(app_id: str) -> bool:
    """Sprawdza czy flatpak o danym ID jest zainstalowany."""
    try:
        r = subprocess.run(
            ["flatpak", "info", app_id],
            capture_output=True, text=True, timeout=10
        )
        return r.returncode == 0
    except Exception:
        return False

# =============================================================================
# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
# =============================================================================
# pag flatpak <query>          → szuka i proponuje instalację (jeśli nie zainstalowany)
# pag flatpak search <query>   → tylko szuka
# pag flatpak install <query>  → instaluje
# pag flatpak remove <id>      → usuwa
# pag flatpak list             → lista zainstalowanych
# pag flatpak update           → aktualizuje wszystkie
# pag flatpak info <id>        → szczegóły flatpaka

def cmd_flatpak(args: list):
    """
    Główna komenda flatpak – inteligentnie rozpoznaje intencję:
      pag flatpak firefox         → szuka i instaluje (jeśli nieznaleziony → szuka)
      pag flatpak search firefox  → tylko wyszukiwanie
      pag flatpak install ...     → bezpośrednia instalacja
      pag flatpak remove ...      → odinstalowanie
      pag flatpak list            → lista
      pag flatpak update          → aktualizacja
      pag flatpak info ...        → szczegóły
    """
    if not _check_flatpak():
        return 1

    if not args:
        # Bez argumentów – domyślnie lista
        return cmd_flatpak_list()

    subcmd = args[0].lower()
    rest = args[1:]

    # ── Podkomendy jawne ────────────────────────────────────────────────
    if subcmd == "search":
        if not rest:
            print(_("flatpak_usage")); return 1
        return cmd_flatpak_search(" ".join(rest))

    elif subcmd == "install":
        if not rest:
            print(_("flatpak_usage")); return 1
        return _flatpak_smart_install(rest)

    elif subcmd == "remove" or subcmd == "uninstall":
        if not rest:
            print(_("flatpak_usage")); return 1
        return _flatpak_smart_remove(rest)

    elif subcmd == "list":
        return cmd_flatpak_list()

    elif subcmd == "update":
        return cmd_flatpak_update()

    elif subcmd == "info":
        if not rest:
            print(_("flatpak_usage")); return 1
        return cmd_flatpak_info(rest[0])

    else:
        # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
        # Sprawdź czy to zainstalowany flatpak → pokaż info
        # Jeśli nie → szukaj i zaproponuj instalację
        query = " ".join(args)

        # Najpierw sprawdź czy już zainstalowany
        if _flatpak_is_installed(query):
            print(f"  📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
            return cmd_flatpak_info(query)

        # Szukaj we Flathub
        print(f"  {_('flatpak_searching', query)}")
        best = _flatpak_find_best(query)
        if not best:
            print(f"  ❌ '{query}' – {_('flatpak_not_found')}")
            return 1

        print(f"\n  {_c('cyan', best['name'])} ({best['app_id']})")
        if best["version"]:
            print(f"  {_('flatpak_info_version')}: {best['version']}")
        if best["description"]:
            print(f"  {best['description']}")

        ans = input(f"\n  {_('flatpak_install_prompt', best['name'])}").strip().lower()
        if ans and ans not in ("t", "y"):
            print(_("cancelled"))
            return 0

        return _flatpak_do_install(best["app_id"])

def _flatpak_smart_install(names: list) -> int:
    """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
    failed = 0
    for name in names:
        if "." in name and "/" not in name:
            # Wygląda na pełne app_id (np. org.mozilla.firefox)
            app_id = name
        else:
            # Szukaj najlepszego dopasowania
            best = _flatpak_find_best(name)
            if not best:
                print(f"  ❌ '{name}' – {_('flatpak_not_found')}")
                failed += 1
                continue
            app_id = best["app_id"]
            print(f"  → {best['name']} ({app_id})")

        if _flatpak_do_install(app_id) != 0:
            failed += 1
    return 1 if failed else 0

def _flatpak_do_install(app_id: str) -> int:
    """Wykonuje właściwą instalację flatpaka."""
    print(f"  {_('flatpak_installing', app_id)}")
    result = subprocess.run(
        ["flatpak", "install", "-y", "flathub", app_id],
        check=False, timeout=600
    )
    if result.returncode == 0:
        print(f"  ✅ {_('flatpak_installed', app_id)}")
        return 0
    else:
        print(f"  ❌ {_('download_fail')}: {app_id}")
        return 1

def _flatpak_smart_remove(names: list) -> int:
    """Usuwa flatpaki – obsługuje nazwy częściowe."""
    # Pobierz listę zainstalowanych
    try:
        r = subprocess.run(
            ["flatpak", "list", "--columns=application,name"],
            capture_output=True, text=True, timeout=10
        )
        installed = {}
        for line in r.stdout.strip().split("\n"):
            parts = line.split("\t")
            if len(parts) >= 2:
                installed[parts[0].strip()] = parts[1].strip()
    except Exception:
        installed = {}

    failed = 0
    for name in names:
        app_id = name

        # Jeśli nie podano pełnego ID – spróbuj dopasować
        if name not in installed:
            matches = {aid: aname for aid, aname in installed.items()
                       if name.lower() in aid.lower() or name.lower() in aname.lower()}
            if len(matches) == 0:
                print(f"  ❌ '{name}' – {_('flatpak_not_installed', name)}")
                failed += 1
                continue
            elif len(matches) == 1:
                app_id = list(matches.keys())[0]
                print(f"  → {matches[app_id]} ({app_id})")
            else:
                print(f"\n  Wiele dopasowań dla '{name}':")
                for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
                    print(f"  {i}. {aname} ({aid})")
                try:
                    choice = input(f"\n  Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
                    if not choice:
                        failed += 1
                        continue
                    aid_list = sorted(matches.keys())
                    app_id = aid_list[int(choice) - 1]
                except (ValueError, IndexError):
                    failed += 1
                    continue

        print(f"  🗑 {app_id} ...", end=" ", flush=True)
        result = subprocess.run(
            ["flatpak", "uninstall", "-y", app_id],
            capture_output=True, text=True, timeout=120
        )
        if result.returncode == 0:
            print("✅")
            print(f"  {_('flatpak_removed', app_id)}")
        else:
            print("❌")
            failed += 1
    return 1 if failed else 0

def cmd_flatpak_search(q: str):
    """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
    if not _check_flatpak():
        return 1
    results = _flatpak_search_raw(q)
    if not results:
        print(f"  ❌ '{q}' – {_('flatpak_not_found')}")
        return 1
    print(f"\n  {_('flatpak_found', len(results))}")
    shown = results[:30]  # max 30 wyników
    for i, r in enumerate(shown, 1):
        installed = "📦 " if _flatpak_is_installed(r["app_id"]) else "   "
        print(f"  {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
        if r["version"]:
            print(f"      {_('flatpak_info_version')}: {r['version']}  |  {_('flatpak_info_branch')}: {r['branch']}")
        if r["description"]:
            desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
            print(f"      {_c('dim', desc)}")
    if len(results) > 30:
        print(f"  ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")

    # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
    try:
        ans = input(f"\n  Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
    except (EOFError, KeyboardInterrupt):
        return 0
    if ans:
        try:
            idx = int(ans) - 1
            if 0 <= idx < len(shown):
                return _flatpak_do_install(shown[idx]["app_id"])
            print(_("cancelled"))
        except (ValueError, IndexError):
            print(_("cancelled"))
    return 0

def cmd_flatpak_list():
    """Wyświetla zainstalowane flatpaki."""
    if not _check_flatpak():
        return 1
    r = subprocess.run(
        ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
        capture_output=True, text=True, timeout=10
    )
    lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
    if not lines:
        print("  (brak zainstalowanych flatpaków)")
        return 0
    print(f"  Zainstalowane flatpaki ({len(lines)}):")
    for line in lines:
        parts = line.split("\t")
        if len(parts) >= 3:
            app_id, name, version = parts[0], parts[1], parts[2]
            size = parts[4] if len(parts) > 4 else ""
            size_str = f"  ({size})" if size else ""
            print(f"  📦 {_c('bold', name)} {version}{size_str}")
            print(f"     {_c('dim', app_id)}")
    return 0

def cmd_flatpak_update():
    """Aktualizuje wszystkie flatpaki."""
    if not _check_flatpak():
        return 1
    print("  🔄 Aktualizacja flatpaków...")
    result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
    if result.returncode == 0:
        print(f"  ✅ {_('flatpak_updated')}")
    return result.returncode

def cmd_flatpak_info(app_id: str):
    """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
    if not _check_flatpak():
        return 1

    # Najpierw sprawdź zainstalowany
    info = _flatpak_get_installed_info(app_id)
    if info:
        print(f"\n  📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
        print(f"  {'─' * 45}")
        print(f"  {_('flatpak_info_id'):<16} {app_id}")
        print(f"  {_('flatpak_info_version'):<16} {info['version']}")
        print(f"  {_('flatpak_info_branch'):<16} {info['branch']}")
        print(f"  {_('flatpak_info_origin'):<16} {info['origin']}")
        if info["size"]:
            print(f"  {_('flatpak_info_size'):<16} {info['size']}")
        if info["description"]:
            print(f"  {_('flatpak_info_desc'):<16} {info['description']}")
        return 0

    # Szukaj we Flathub
    results = _flatpak_search_raw(app_id)
    exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
    if not exact:
        # Spróbuj częściowego dopasowania
        if results:
            exact = [results[0]]
        else:
            print(f"  ❌ '{app_id}' – {_('flatpak_not_found')}")
            return 1

    r = exact[0]
    print(f"\n  📦 {_c('bold', r['name'])} (Flathub)")
    print(f"  {'─' * 45}")
    print(f"  {_('flatpak_info_id'):<16} {r['app_id']}")
    print(f"  {_('flatpak_info_version'):<16} {r['version']}")
    if r["description"]:
        print(f"  {_('flatpak_info_desc'):<16} {r['description']}")
    print(f"\n  💡 Aby zainstalować: pag flatpak install {r['app_id']}")
    return 0

# =============================================================================
# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
# =============================================================================

# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]

def _is_kernel_package(name: str) -> bool:
    """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
    name_lower = name.lower()
    return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)

def _rebuild_initramfs(deploy_dir: str = "") -> bool:
    """
    Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
    Używa skryptu pag-initramfs lub ręcznego cpio.
    """
    if deploy_dir:
        root = deploy_dir
    else:
        root = _get_deployment_root()

    if root == PAG_ROOT:
        # Zwykły system – użyj dracut jeśli dostępny
        if shutil.which("dracut"):
            print("  🔧 Przebudowa initramfs (dracut)...")
            result = subprocess.run(
                ["dracut", "--force", "/boot/initramfs.img"],
                capture_output=True, text=True, timeout=120
            )
            return result.returncode == 0
        elif shutil.which("mkinitcpio"):
            print("  🔧 Przebudowa initramfs (mkinitcpio)...")
            result = subprocess.run(
                ["mkinitcpio", "-g", "/boot/initramfs.img"],
                capture_output=True, text=True, timeout=120
            )
            return result.returncode == 0
        else:
            print("  ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
            return False

    # Tryb immutable – budujemy initramfs dla deploymentu
    print("  🔧 Budowanie initramfs dla deploymentu...")

    # Sprawdź czy mamy nasz skrypt init
    pag_init_script = "/usr/share/pag/initramfs-init"
    if not os.path.exists(pag_init_script):
        # Szukaj w źródłach (developerski fallback)
        alt_paths = [
            os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
            "/usr/share/pag/init",
        ]
        for p in alt_paths:
            if os.path.exists(p):
                pag_init_script = p
                break

    if not os.path.exists(pag_init_script):
        print("  ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
        return False

    boot_dir = os.path.join(root, "boot")
    os.makedirs(boot_dir, exist_ok=True)

    # Znajdź jądro (vmlinuz-*)
    kernels = sorted(
        [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
        reverse=True
    ) if os.path.exists(boot_dir) else []
    if not kernels:
        print("  ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
        return False

    kernel_ver = kernels[0].replace("vmlinuz-", "")
    print(f"  🐧 Jądro: {kernel_ver}")

    # Buduj initramfs ręcznie (cpio)
    tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
    try:
        # Podstawowa struktura
        for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
                   "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
            os.makedirs(os.path.join(tmpdir, d), exist_ok=True)

        # Skopiuj init
        shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
        os.chmod(os.path.join(tmpdir, "init"), 0o755)

        # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
        busybox_paths = [
            os.path.join(root, "usr/bin/busybox"),
            os.path.join(root, "bin/busybox"),
            "/usr/bin/busybox",
            "/bin/busybox",
        ]
        busybox = None
        for bp in busybox_paths:
            if os.path.exists(bp):
                busybox = bp
                break

        if busybox:
            shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
            # Utwórz symlinki dla podstawowych komend
            for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
                         "readlink", "mkdir", "switch_root", "cp", "rm"]:
                link = os.path.join(tmpdir, "bin", cmd)
                if not os.path.exists(link):
                    os.symlink("busybox", link)
            # /bin/sh → busybox
            if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
                os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
        else:
            # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
            for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
                src = os.path.join(root, "usr/bin", tool)
                if not os.path.exists(src):
                    src = os.path.join(root, "bin", tool)
                if os.path.exists(src):
                    dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
                    shutil.copy2(src, dest)
                    # Kopiuj zależności .so
                    _copy_libs_for_binary(src, tmpdir, root)

        # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
        modules_src = os.path.join(root, "lib/modules", kernel_ver)
        if os.path.isdir(modules_src):
            modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
            # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
            for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
                         "kernel/drivers/scsi", "kernel/drivers/virtio",
                         "modules.order", "modules.builtin"]:
                src_sub = os.path.join(modules_src, sub)
                if os.path.exists(src_sub):
                    dst_sub = os.path.join(modules_dst, sub)
                    os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
                    if os.path.isdir(src_sub):
                        shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True)
                    else:
                        shutil.copy2(src_sub, dst_sub)

        # Pakuj do initramfs.img
        initramfs_path = os.path.join(boot_dir, "initramfs.img")
        old_cwd = os.getcwd()
        os.chdir(tmpdir)
        try:
            with open(initramfs_path + ".tmp", "wb") as out:
                _run_cpio_pipeline(tmpdir, out)
            os.rename(initramfs_path + ".tmp", initramfs_path)
        finally:
            os.chdir(old_cwd)

        size_mb = os.path.getsize(initramfs_path) / 1048576
        print(f"  ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
        return True

    except Exception as e:
        print(f"  ❌ Błąd budowy initramfs: {e}")
        return False
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)


def _run_cpio_pipeline(tmpdir: str, out):
    """find . | cpio -oH newc | gzip — bez shell=True.

    Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
    (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
    """
    find = subprocess.Popen(["find", "."], cwd=tmpdir, stdout=subprocess.PIPE)
    cpio = subprocess.Popen(["cpio", "-oH", "newc"], cwd=tmpdir,
                            stdin=find.stdout, stdout=subprocess.PIPE)
    find.stdout.close()  # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
    gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
    cpio.stdout.close()
    try:
        gzip.wait(timeout=120)
        if gzip.returncode != 0:
            raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
        cpio.wait(timeout=30)
        find.wait(timeout=30)
    except subprocess.TimeoutExpired:
        for p in (gzip, cpio, find):
            p.kill()
        raise
    finally:
        for p in (find, cpio, gzip):
            if p.poll() is None:
                p.kill()
    # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
    if cpio.returncode != 0:
        raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
    if find.returncode != 0:
        raise subprocess.CalledProcessError(find.returncode, ["find"])


def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
    """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
    try:
        result = subprocess.run(
            ["ldd", binary], capture_output=True, text=True, timeout=10
        )
        for line in result.stdout.split("\n"):
            m = re.search(r'=>\s+(/\S+)', line)
            if m:
                lib_path = m.group(1)
                lib_rel = lib_path.lstrip("/")
                lib_dest = os.path.join(dest_dir, lib_rel)
                if not os.path.exists(lib_dest):
                    os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
                    # Szukaj w deployment root lub systemie
                    if os.path.exists(lib_path):
                        shutil.copy2(lib_path, lib_dest)
                    else:
                        alt = os.path.join(root, lib_rel)
                        if os.path.exists(alt):
                            shutil.copy2(alt, lib_dest)
    except Exception:
        pass


def cmd_initramfs_update():
    """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
    ensure_dirs()
    deploy_dir = _get_deployment_root()
    if deploy_dir != PAG_ROOT:
        print(f"🏗️  Deployment: {os.path.basename(deploy_dir)}")
    ok = _rebuild_initramfs(deploy_dir)
    if ok:
        print("✅ Initramfs zaktualizowany.")
        # Po initramfs – zaktualizuj też GRUB
        _update_grub_config()
    else:
        print("❌ Błąd aktualizacji initramfs.")
    return 0 if ok else 1


def _update_grub_config():
    """
    Generuje wpisy GRUB dla wszystkich deploymentów.
    Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
    """
    grub_cfg = "/boot/grub/grub.cfg"
    if not os.path.exists(os.path.dirname(grub_cfg)):
        return  # brak GRUB

    deployments = _load_deployments()
    root_dev = _detect_root_device()

    lines = [
        "# =====================================================================",
        "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
        f"# Data: {datetime.now().isoformat()}",
        "# =====================================================================",
        "",
    ]

    # Domyślny – ostatni (najnowszy) deployment
    if deployments:
        latest = deployments[-1]["id"]
        lines.append(f"set default=0")
        lines.append(f"set timeout=5")
    else:
        lines.append("set default=0")
        lines.append("set timeout=5")
    lines.append("")

    # Wpisy dla każdego deploymentu (od najnowszego)
    entry_num = 0
    for d in reversed(deployments):
        deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
        boot_dir = os.path.join(deploy_dir, "boot")
        kernels = sorted(
            [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
            reverse=True
        ) if os.path.isdir(boot_dir) else []

        kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
        initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
        initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""

        active_mark = " [AKTYWNY]" if d.get("active") else ""
        pkg_list = ", ".join(d.get("packages", [])[:3])
        label = f"Pagan Linux – {d['id']}{active_mark}"

        lines.append(f"menuentry '{label}' {{")
        if kernel_path:
            lines.append(f"    linux {kernel_path} root={root_dev} rw quiet")
        else:
            lines.append(f"    # Brak jądra w tym deploymencie")
        if initrd_line:
            lines.append(f"    {initrd_line}")
        lines.append("}")
        lines.append("")
        entry_num += 1

    # Wpis fallback: zwykły root (gdyby wszystko padło)
    lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
    lines.append(f"    linux /boot/vmlinuz-* root={root_dev} rw quiet")
    lines.append(f"    initrd /boot/initramfs.img")
    lines.append("}")
    lines.append("")

    # Zapisz
    os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
    with open(grub_cfg, "w") as f:
        f.write("\n".join(lines))

    print("  📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")


def _detect_root_device() -> str:
    """Wykrywa device partycji root (np. /dev/sda1)."""
    try:
        result = subprocess.run(
            ["findmnt", "-n", "-o", "SOURCE", "/"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except Exception:
        pass
    return "/dev/sda1"  # fallback


def cmd_grub_update():
    """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
    ensure_dirs()
    print("📋 Aktualizacja konfiguracji GRUB...")
    _update_grub_config()
    print("✅ GRUB zaktualizowany.")
    return 0

def cmd_deploy_list():
    """Wyświetla listę wszystkich deploymentów."""
    deployments = _load_deployments()
    if not deployments:
        print(_("no_deployments")); return

    print(_("deployments_list", len(deployments)))
    active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""

    for d in reversed(deployments):
        marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
        print(f"  {d['id']}{marker}")
        print(f"     {d['action']}: {', '.join(d['packages'][:5])}")
        if len(d.get('packages', [])) > 5:
            print(f"     +{len(d['packages']) - 5} więcej...")
        print(f"     {d['timestamp']}")


def cmd_deploy_rollback():
    """Przełącza na poprzedni deployment."""
    deployments = _load_deployments()
    active_indices = [i for i, d in enumerate(deployments) if d.get("active")]

    if len(deployments) < 2:
        print(f"❌ {_('deploy_rollback_fail')}"); return 1

    current_idx = active_indices[0] if active_indices else len(deployments) - 1
    prev_idx = current_idx - 1 if current_idx > 0 else -1

    if prev_idx < 0:
        print(f"❌ {_('deploy_rollback_fail')}"); return 1

    prev = deployments[prev_idx]
    prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])

    if not os.path.isdir(prev_dir):
        print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1

    print(f"⏪ Przywracanie deploymentu: {prev['id']}")
    print(f"   {prev['action']}: {', '.join(prev['packages'][:5])}")

    if os.environ.get("PAG_YES", "") == "1":
        print(_("continue_q") + " t (--yes)")
    else:
        ans = input(_("continue_q")).strip().lower()
        if ans and ans not in ("t", "y"):
            return 0

    _switch_deployment(prev_dir)

    for d in deployments:
        d["active"] = (d["id"] == prev["id"])
    _save_deployments(deployments)

    _update_grub_config()
    print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
    print("  💡 Restart wymagany do przeładowania systemu.")
    return 0


def cmd_deploy_cleanup(keep: int = 3):
    """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
    deployments = _load_deployments()

    if len(deployments) <= keep:
        print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0

    to_remove = deployments[:-keep]
    removed = 0

    for d in to_remove:
        deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
        if os.path.isdir(deploy_dir):
            shutil.rmtree(deploy_dir, ignore_errors=True)
            removed += 1

    remaining = deployments[-keep:]
    _save_deployments(remaining)

    print(f"✅ {_('deploy_cleanup_ok', removed)}")
    return 0


# =============================================================================
# POMOCNICZE
# =============================================================================

def _resolve_deps(names, repo, installed):
    resolved, visited = [], set()
    missing = []  # zależności których nie ma ani w repo ani zainstalowane

    def visit(name):
        if name in visited: return

        # Rozwijanie wirtualnych zależności przez provides
        target = _resolve_provides(name, repo)

        if target in visited: return
        visited.add(target)
        if target in repo:
            for dep in repo[target].dependencies:
                real_dep = _resolve_provides(dep, repo)
                real_target = real_dep if real_dep in repo else dep

                # Sprawdź czy zależność jest dostępna
                if real_target not in installed and real_target not in repo:
                    if dep not in missing:
                        missing.append(dep)

                if dep not in installed:
                    visit(real_target)
        elif target not in installed:
            # Pakiet nie istnieje ani w repo ani zainstalowany
            if target not in missing:
                missing.append(target)

        if target not in installed and target not in resolved:
            resolved.append(target)

    for name in names:
        visit(name)

    # Zwróć brakujące (do sprawdzenia przez wywołującego)
    return resolved, missing

def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
    """
    Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
    Zwraca liczbę brakujących zależności.
    """
    # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
    SYSTEM_BASE = {
        "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
        "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
        "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
        "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
    }
    all_missing = []
    all_warnings = []

    for pkg_name in to_install:
        pkg = repo.get(pkg_name)
        if not pkg:
            continue

        for dep in pkg.dependencies:
            if dep in SYSTEM_BASE:
                continue  # bazowy system dostarcza tę zależność
            real_dep = _resolve_provides(dep, repo)
            # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
            in_repo = real_dep in repo
            in_installed = real_dep in installed
            will_be_installed = real_dep in to_install

            if not in_repo and not in_installed and not will_be_installed:
                if dep not in all_missing:
                    all_missing.append((pkg_name, dep))
            elif in_repo and not in_installed and not will_be_installed:
                if dep not in [w[1] for w in all_warnings]:
                    all_warnings.append((pkg_name, dep, real_dep))

    if all_missing:
        print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
        for pkg, dep in all_missing:
            print(f"   {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
        print()

    if all_warnings:
        print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
        for pkg, dep, real in all_warnings:
            print(f"   {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
        print()

    return len(all_missing)

def _download_pkg(pkg):
    url = f"{pkg.repo_url}/{pkg.filename}"
    dest = os.path.join(PAG_CACHE, pkg.filename)
    if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
        _download_pkg_sig(pkg, dest)  # upewnij się, że sygnatura jest w cache
        return dest
    try:
        req = Request(url, headers={"User-Agent":"pag/3.0"})
        with urlopen(req, timeout=600) as resp:
            total = int(resp.headers.get("Content-Length", 0))
            bar = DownloadBar(pkg.filename, total)
            with open(dest, "wb") as f:
                while True:
                    chunk = resp.read(65536)
                    if not chunk:
                        break
                    f.write(chunk)
                    bar.update(len(chunk))
            bar.close()
        if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
            os.remove(dest); return None
        _download_pkg_sig(pkg, dest)
        return dest
    except Exception as e:
        print(f"  ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
        return None

def _download_pkg_sig(pkg, dest):
    """Pobiera podpis pakietu (.asc, fallback .sig) obok paczki w cache."""
    for ext in (".asc", ".sig"):
        sig_dest = dest + ext
        if os.path.exists(sig_dest):
            return
        try:
            req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
            with urlopen(req, timeout=30) as resp:
                with open(sig_dest, "wb") as f:
                    f.write(resp.read())
            return
        except Exception:
            continue

def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
    """
    Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
    Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
    Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
    """
    results = {}
    total = len(pkgs)
    completed = 0
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
        for future in as_completed(future_to_pkg):
            pkg = future_to_pkg[future]
            try:
                results[pkg.name] = future.result()
            except Exception:
                results[pkg.name] = None
            completed += 1
            # Pasek postępu
            pct = completed / total * 100
            filled = int(20 * pct / 100)
            bar = "█" * filled + "░" * (20 - filled)
            print(f"\r  ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
    print(file=sys.stderr)  # nowa linia po zakończeniu
    return results

def load_world():
    if not os.path.exists(WORLD_FILE): return set()
    return {l.strip() for l in open(WORLD_FILE) if l.strip()}

def save_world(w):
    with open(WORLD_FILE,"w") as f:
        for n in sorted(w): f.write(f"{n}\n")

def _find_orphans(installed, world):
    needed = set(world)
    changed = True
    while changed:
        changed = False
        for n in list(needed):
            for dep in installed.get(n,{}).get("dependencies",[]):
                if dep not in needed and dep in installed:
                    needed.add(dep); changed = True
    return {n for n in installed if n not in needed}

# =============================================================================
# MAIN
# =============================================================================

USAGE_EN = """pag v3 – Pagan Linux Package Manager

BASIC:
  pag install <pkg>...       Install packages
  pag remove  <pkg>...       Remove packages
  pag update [--force]       Refresh repo indexes
  pag sync                   Refresh repo indexes (alias for update)
  pag upgrade                Upgrade all packages
  pag list [--installed]     List available / installed
  pag search <query>         Search packages
  pag info   <pkg>           Package details
  pag files  <pkg>           List package files
  pag verify [--deep]        Verify integrity (--deep = SHA256 per file)
  pag clean                  Clear download cache
  pag stats                  System statistics
  pag download <pkg>...      Download packages to cache (offline prep)

SECURITY:
  pag key-add <url|file>     Import GPG key
  pag key-list               List trusted keys
  pag key-remove <id>        Remove key
  pag key-trust <repo>       Pin repo signing key fingerprint (no TOFU)
  pag key-untrust <repo>     Forget repo fingerprint (back to TOFU)
  pag key-trusted            List pinned repo fingerprints

ADVANCED:
  pag why     <pkg>          Show why a package is installed
  pag autoremove             Auto-remove orphaned dependencies
  pag pin <pkg> [ver]        Pin package version
  pag unpin <pkg>            Unpin
  pag pinned                 List pinned
  pag history                Transaction history
  pag rollback               Rollback last transaction
  pag remove-orphans         Remove orphaned deps
  pag repo-add <url> [name]  Add repository (drop-in /etc/pag/repos/)
  pag repo-list              List repositories

FLATPAK:
  pag flatpak [<query>]      Search & install (smart)
  pag flatpak search <q>     Search Flathub
  pag flatpak install <id>   Install flatpak
  pag flatpak remove <id>    Remove flatpak
  pag flatpak list           List installed flatpaks
  pag flatpak update         Update all flatpaks
  pag flatpak info <id>      Show flatpak details

IMMUTABLE OS (PAG_IMMUTABLE=1):
  pag deploy-list            List all deployments
  pag deploy-rollback        Switch to previous deployment
  pag deploy-cleanup [N]     Remove old deployments (keep last N, default 3)
  pag initramfs-update       Rebuild initramfs for current kernel/deployment
  pag grub-update            Regenerate GRUB entries for all deployments
"""

USAGE_PL = """pag v3 – Pagan Linux Package Manager

PODSTAWOWE:
  pag install <pkg>...       Instalacja pakietów
  pag remove  <pkg>...       Usuwanie pakietów
  pag update [--force]       Odśwież indeksy repozytoriów
  pag sync                   Odśwież indeksy repozytoriów (alias dla update)
  pag upgrade                Aktualizacja wszystkich pakietów
  pag list [--installed]     Lista dostępnych / zainstalowanych
  pag search <query>         Szukaj pakietów
  pag info   <pkg>           Szczegóły pakietu
  pag files  <pkg>           Lista plików pakietu
  pag verify [--deep]        Weryfikacja integralności
  pag clean                  Wyczyść cache pobierania
  pag stats                  Statystyki systemu
  pag download <pkg>...      Pobierz do cache (offline)

BEZPIECZEŃSTWO:
  pag key-add <url|file>     Importuj klucz GPG
  pag key-list               Lista zaufanych kluczy
  pag key-remove <id>        Usuń klucz
  pag key-trust <repo>       Przypnij fingerprint klucza repo (bez TOFU)
  pag key-untrust <repo>     Zapomnij fingerprint repo (powrót do TOFU)
  pag key-trusted            Lista przypiętych fingerprintów repo

ZAAWANSOWANE:
  pag why     <pkg>          Dlaczego pakiet jest zainstalowany
  pag autoremove             Usuń osierocone zależności
  pag pin <pkg> [ver]        Przypnij wersję pakietu
  pag unpin <pkg>            Odepnij
  pag pinned                 Lista przypiętych
  pag history                Historia transakcji
  pag rollback               Cofnij ostatnią transakcję
  pag remove-orphans         Usuń osierocone zależności
  pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
  pag repo-list              Lista repozytoriów

FLATPAK:
  pag flatpak [<query>]      Szukaj i instaluj
  pag flatpak search <q>     Szukaj na Flathub
  pag flatpak install <id>   Zainstaluj flatpak
  pag flatpak remove <id>    Usuń flatpak
  pag flatpak list           Lista zainstalowanych
  pag flatpak update         Aktualizuj wszystkie
  pag flatpak info <id>      Szczegóły flatpaka

IMMUTABLE OS (PAG_IMMUTABLE=1):
  pag deploy-list            Lista wdrożeń
  pag deploy-rollback        Przełącz na poprzednie wdrożenie
  pag deploy-cleanup [N]     Usuń stare wdrożenia (zachowaj N, domyślnie 3)
  pag initramfs-update       Przebuduj initramfs
  pag grub-update            Regeneruj wpisy GRUB"""

def _get_usage():
    if LANG == "pl":
        return USAGE_PL
    return USAGE_EN


def main():
    if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
        print(f"pag {PAG_VERSION}")
        sys.exit(0)
    if len(sys.argv) < 2:
        print(_get_usage()); sys.exit(0)

    cmd = sys.argv[1]
    args = sys.argv[2:]

    # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
    READ_ONLY = {
        "list": lambda: cmd_list("--installed" in args),
        "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
        "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
        "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
        "verify": lambda: cmd_verify("--deep" in args),
        "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
        "stats": cmd_stats,
        "pinned": cmd_pinned,
        "history": cmd_history,
        "repo-list": cmd_repo_list,
        "key-list": cmd_key_list,
        "key-trusted": cmd_key_trusted,
        "flatpak": lambda: cmd_flatpak(args),
        "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
        "flatpak-list": cmd_flatpak_list,
        "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
        "deploy-list": cmd_deploy_list,
        "deploy": cmd_deploy_list,
    }

    if cmd in READ_ONLY:
        sys.exit(READ_ONLY[cmd]() or 0)

    # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
    WRITE_CMDS = {
        "install", "remove", "update", "sync", "upgrade", "clean", "download",
        "autoremove", "remove-orphans", "pin", "unpin", "rollback",
        "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
        "self-update",
        "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
        "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
    }
    if cmd not in WRITE_CMDS:
        sys.exit(_smart_search(" ".join([cmd] + args)) or 0)

    # Obsługa flag globalnych (-y/--yes)
    global_args = []
    for a in args:
        if a in ("-y", "--yes"):
            os.environ["PAG_YES"] = "1"
        else:
            global_args.append(a)
    args = global_args

    # --- Komendy ZAPISU (wymagają roota) ---
    if os.geteuid() != 0:
        print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)

    ensure_dirs()

    with DatabaseLock():
        WRITE_COMMANDS = {
            "install": lambda: cmd_install(args),
            "remove": lambda: cmd_remove(args),
            "update": cmd_update,
            "sync": cmd_update,
            "upgrade": cmd_upgrade,
            "clean": cmd_clean,
            "download": lambda: cmd_download(args),
            "autoremove": cmd_autoremove,
            "remove-orphans": cmd_remove_orphans,
            "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
            "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
            "rollback": cmd_rollback,
            "repo-add": lambda: cmd_repo_add(args[0], args[1] if len(args) > 1 else "") if args else print("Usage: pag repo-add <url> [name]"),
            "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
            "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
            "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
            "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
            "self-update": cmd_self_update,
            "flatpak": lambda: cmd_flatpak(args),
            "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
            "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
            "flatpak-update": cmd_flatpak_update,
            "deploy-rollback": cmd_deploy_rollback,
            "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
            "initramfs-update": cmd_initramfs_update,
            "grub-update": cmd_grub_update,
        }

        fn = WRITE_COMMANDS.get(cmd)
        if fn:
            sys.exit(fn() or 0)
        # Should never reach here – _smart_search handles unknowns
        sys.exit(_smart_search(" ".join([cmd] + args)) or 0)

if __name__ == "__main__":
    main()