#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════════════════════╗
║                    PAG - Pagan Linux Package Manager v3.3.3                  ║
║         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
  - Hooki: pre/post-install, pre/post-remove
  - Głęboka weryfikacja SHA256 per-plik
  - Pełny rollback – cofa fizyczne pliki
  - Blokada flock – tylko jedna instancja
  - Transakcje z migawkami
  - Cache HTTP (ETag/If-Modified-Since)
  - Wielojęzyczność (i18n) – PL, EN

FORMAT PAKIETU (.pkg.tar.xz):
  ├── 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
"""

import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re
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.3"
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"

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

# =============================================================================
# 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) -> str:
    """Tłumaczy klucz i formatuje argumenty."""
    msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
    if args:
        return msg.format(*args)
    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"
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"
GPG_KEYRING   = "/etc/pag/trusted-keys.gpg"
STAGING_DIR   = "/.pag_staging"          # na tej samej partycji co / (unikamy EXDEV)
PKG_EXT       = ".pkg.tar.xz"
REPO_CACHE_TTL = 3600

# =============================================================================
# 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, 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_KEYRING):
        _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
                  "--fingerprint", 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):
            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:
    def __init__(self):
        self._fd = None
    def __enter__(self):
        self._fd = open(LOCK_FILE, "w")
        try:
            fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except (IOError, OSError):
            print(f"❌ {_('db_locked')}", file=sys.stderr)
            print(f"   {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
            sys.exit(1)
        return self
    def __exit__(self, *args):
        if self._fd:
            fcntl.flock(self._fd, fcntl.LOCK_UN)
            self._fd.close()
            try:
                os.remove(LOCK_FILE)
            except OSError:
                pass  # plik mógł zostać usunięty przez inny proces

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


_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/")

def _check_path_safety(name: str) -> bool:
    for prefix in _ALLOWED_PREFIXES:
        if name == prefix.rstrip("/") or name.startswith(prefix):
            return True
    return False


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

def _acquire_lock():
    import fcntl
    os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
    lf = open(LOCK_FILE, "w")
    try:
        fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
        return lf
    except (IOError, OSError):
        print(f"  {_("sec_locked")}")
        sys.exit(1)

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:
      - Blokuje ścieżki absolutne i z '..' (path traversal)
      - Blokuje niebezpieczne symlinki
      - Zachowuje oryginalne uprawnienia plików
    """
    for member in tar.getmembers():
        name = member.name

        # --- Ochrona przed Directory Traversal ---
        # Blokuj ścieżki absolutne (zaczynające się od /)
        if name.startswith('/'):
            continue
        # Blokuj ścieżki zawierające '..'
        if '..' in name.split('/'):
            continue
        if not _check_path_safety(name):
            print(f"  BLOCKED: {name}")
            continue

        # --- Ochrona dla symlinków i hardlinków ---
        if member.issym() or member.islnk():
            link = member.linkname
            # Blokuj linki do ścieżek absolutnych
            if link.startswith('/'):
                continue
            # Blokuj linki z '..'
            if '..' in link.split('/'):
                continue

        # Rozpakuj z zachowaniem metadanych
        target = os.path.join(dest, name)
        tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
        _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 _version_newer(a: str, b: str) -> bool:
    def parse(v):
        parts = []
        for p in v.replace("-",".").replace("_",".").split("."):
            try: parts.append((0, int(p)))
            except ValueError: parts.append((1, p))
        return parts
    try: return parse(a) > parse(b)
    except: 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 get_repos():
    repos = []
    if os.path.exists(REPOS_CONF):
        for line in open(REPOS_CONF):
            line = line.strip()
            if line and not line.startswith("#"):
                repos.append(line.rstrip("/"))
    return repos or DEFAULT_REPOS

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.

    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ć.
    """
    insecure = os.environ.get("PAG_INSECURE", "") == "1"

    if not os.path.exists(GPG_KEYRING):
        if insecure:
            return True  # brak GPG keyring – 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)

    result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
                       "--verify", sig_path, cache_path,
                       capture_output=True, text=True, timeout=30)
    if result.returncode != 0:
        # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
        # jak apt) – gdy w keyringu brakuje klucza (No public key).
        _stderr = (result.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("--no-default-keyring", "--keyring", GPG_KEYRING,
                              "--import", tmp.name, capture_output=True, timeout=30)
                    os.unlink(tmp.name)
                print(f"  🔑 Importowano klucz repo z {repo_url}/paganos.asc")
                result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
                                   "--verify", sig_path, cache_path,
                                   capture_output=True, text=True, timeout=30)
            except Exception:
                pass
        if result.returncode != 0:
            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

    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):
    """Weryfikuje podpis GPG pakietu.

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

    result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
                       "--verify", sig_path, pkg_path,
                       capture_output=True, text=True, timeout=30)
    if result.returncode != 0:
        if insecure:
            return True, f"(invalid signature – PAG_INSECURE: {result.stderr[:80]})"
        return False, f"NIEPRAWIDŁOWY PODPIS GPG: {result.stderr[:80]}"

    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("--no-default-keyring", "--keyring", GPG_KEYRING,
                          "--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("--no-default-keyring", "--keyring", GPG_KEYRING,
                  "--import", source, capture_output=True, timeout=30)
    print(f"✅ {_('key_imported')}")

def cmd_key_list():
    if not os.path.exists(GPG_KEYRING):
        print(_("no_keys")); return
    result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
                       "--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("--no-default-keyring", "--keyring", GPG_KEYRING,
              "--batch", "--yes", "--delete-key", key_id,
              capture_output=True, timeout=30)
    print(f"✅ {_('key_removed', key_id)}")

# =============================================================================
# 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 = "") -> 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 /.
    """
    # 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)
        # 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)

    # 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 = "") -> Tuple[bool, List[dict]]:
    """
    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]).
    """
    staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
    journal = []
    installed_files = []

    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, []

        # 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 {}

        # 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)
                if not ok:
                    # Cofnij wszystkie operacje
                    _rollback_journal(journal, staging)
                    return False, []

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

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

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

    except Exception as e:
        _rollback_journal(journal, staging)
        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
    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

# =============================================================================
# HOOKI
# =============================================================================

def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo):
    """Uruchamia skrypt hooka jeśli istnieje."""
    hook_path = os.path.join(hooks_dir, hook_name)
    if not os.path.exists(hook_path):
        return
    os.chmod(hook_path, 0o755)
    env = os.environ.copy()
    env["PKG_NAME"] = pkg.name
    env["PKG_VERSION"] = pkg.version
    env["PKG_ACTION"] = hook_name
    try:
        subprocess.run([hook_path], env=env, timeout=60, check=False)
    except Exception:
        pass

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

def _record_transaction(action, packages, success, snapshot, file_journal=None):
    history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
    entry = {
        "action": action, "packages": packages, "success": success,
        "timestamp": datetime.now().isoformat(),
        "snapshot": snapshot,
        "file_journal": file_journal,  # lista plików do wycofania
    }
    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])}")

    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", [])
    if file_journal:
        for fpath in reversed(file_journal):
            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))}")

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

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

def cmd_install(package_names, as_dep=False):
    _lock = _acquire_lock()
    ensure_dirs()
    installed_db = load_json(INSTALLED_DB)
    world = load_world()
    pinned = load_json(PINNED_FILE)
    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)

    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:
            marker = f" [{_('new')}]" if name not in installed_db else ""
            print(f"   {name}-{p.version}{marker}")

    if not as_dep:
        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 = []

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

        # Atomowa instalacja
        ok, files = _atomic_install(pkg_path, pkg, deploy_dir)
        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)

            # 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)
        _record_transaction("install", to_install, False, snapshot)
        return 1

    save_json(INSTALLED_DB, installed_db)
    save_world(world)
    _record_transaction("install", to_install, True, snapshot,
                         file_journal=all_installed_files)

    # --- Tryb immutable: przełącz na nowy deployment ---
    if immutable and not failed:
        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.")

    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):
    """
    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).
    """
    # Przywróć installed_db
    installed_db.clear()
    installed_db.update(snapshot)

    # Usuń fizyczne pliki (odwrotna kolejność)
    root = deploy_dir if is_immutable else PAG_ROOT
    for fpath in reversed(installed_files):
        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)")

    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):
        _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name}))

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

def cmd_self_update():
    """Aktualizuje samego klienta pag z repo (podpisany /stable/pag)."""
    repos = get_repos()
    if not repos:
        print("❌ Brak repozytoriów w konfiguracji.")
        return 1
    base = repos[0]
    print(f"🔄 Sprawdzam aktualizację pag z {base}...")
    tmp_pag = "/tmp/pag.new"
    tmp_sig = "/tmp/pag.new.asc"
    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
    with open(tmp_pag, "wb") as f:
        f.write(data)
    with open(tmp_sig, "wb") as f:
        f.write(sig)

    # Weryfikacja podpisu GPG – bez tego nie instalujemy
    res = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
                   "--verify", tmp_sig, tmp_pag, capture_output=True, text=True)
    if res.returncode != 0:
        print("  ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
        return 1

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

    dst = "/usr/local/bin/pag"
    if os.path.exists(dst):
        shutil.copy2(dst, dst + ".bak")
    shutil.copy2(tmp_pag, dst)
    os.chmod(dst, 0o755)
    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()
    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}")
    ans = input(_("continue_q")).strip().lower()
    if ans and ans not in ("t","y"): return 0
    return cmd_install(upgrades)

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']}")
    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):
    if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
        print(f"  {_("sec_https")}"); return 1
    repos = get_repos()
    url = url.rstrip("/")
    if url in repos: print(f"⚠ {_('repo_exists', url)}"); 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:
                subprocess.run(
                    "find . | cpio -oH newc | gzip",
                    shell=True, stdout=out, check=True, timeout=120,
                    cwd=tmpdir
                )
            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 _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])}")

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

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>         Add repository
  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 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

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>         Dodaj repozytorium
  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,
        "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", "upgrade", "clean", "download",
        "autoremove", "remove-orphans", "pin", "unpin", "rollback",
        "repo-add", "key-add", "key-remove", "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)

    # --- 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,
            "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]) if args else print("Usage: pag repo-add <url>"),
            "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>"),
            "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()